From d8977deaf35123222e4cfa71ba8c6be0fafa778f Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Thu, 30 Jul 2026 00:26:08 -0400 Subject: [PATCH] chore: rename package from `music-remote` to `encore`. --- README.md | 6 +- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 4 +- .../EncoreApplication.kt} | 10 +- .../{musicremote => encore}/MainActivity.kt | 12 +- .../ksamad/{musicremote => encore}/Theme.kt | 4 +- .../data/ConnectionSettings.kt | 2 +- .../data/SettingsRepository.kt | 2 +- .../{musicremote => encore}/mpd/MpdClient.kt | 18 +- .../mpd/MpdCommands.kt | 2 +- .../mpd/MpdConnection.kt | 2 +- .../mpd/MpdConnectionState.kt | 2 +- .../mpd/MpdException.kt | 2 +- .../mpd/MpdProtocol.kt | 2 +- .../mpd/MpdResponse.kt | 2 +- .../mpd/model/MpdAlbum.kt | 4 +- .../mpd/model/MpdSong.kt | 2 +- .../mpd/model/MpdStatistics.kt | 2 +- .../mpd/model/MpdStatus.kt | 2 +- .../playback/ArtImageLoader.kt | 2 +- .../playback/MpdArt.kt | 2 +- .../playback/MpdConnectionManager.kt | 22 ++- .../playback/PlaybackService.kt | 22 +-- .../playback/VolumeKeyDispatcher.kt | 2 +- .../ui/AlbumDetailScreen.kt | 81 +++++--- .../ui/AlbumsScreen.kt | Bin 9482 -> 9467 bytes .../{musicremote => encore}/ui/ArtImage.kt | 4 +- .../ui/EncoreApp.kt} | 10 +- .../ui/NowPlayingScreen.kt | 28 ++- .../ui/PlayerViewModel.kt | 14 +- .../{musicremote => encore}/ui/QueueScreen.kt | 181 ++++++++++++++---- .../ui/SettingsScreen.kt | 4 +- .../ui/SwipeActions.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- app/src/main/res/values/themes.xml | 2 +- .../ExampleUnitTest.kt | 2 +- .../mpd/MpdClientIntegrationTest.kt | 2 +- .../mpd/MpdConnectionTest.kt | 2 +- .../mpd/MpdProtocolTest.kt | 2 +- .../mpd/MpdServerIntegrationTest.kt | 8 +- .../mpd/model/MpdModelTest.kt | 4 +- docs/TODO.md | 4 +- docs/device-testing.md | 4 +- flake.nix | 8 +- gradle.properties | 5 + gradle/wrapper/gradle-wrapper.properties | 2 +- package.nix | 4 +- settings.gradle.kts | 2 +- 48 files changed, 347 insertions(+), 164 deletions(-) rename app/src/main/kotlin/ca/ksamad/{musicremote/MusicRemoteApplication.kt => encore/EncoreApplication.kt} (76%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/MainActivity.kt (89%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/Theme.kt (95%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/data/ConnectionSettings.kt (90%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/data/SettingsRepository.kt (98%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdClient.kt (97%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdCommands.kt (99%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdConnection.kt (99%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdConnectionState.kt (94%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdException.kt (98%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdProtocol.kt (98%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdResponse.kt (98%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/model/MpdAlbum.kt (91%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/model/MpdSong.kt (97%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/model/MpdStatistics.kt (96%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/mpd/model/MpdStatus.kt (98%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/playback/ArtImageLoader.kt (99%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/playback/MpdArt.kt (93%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/playback/MpdConnectionManager.kt (92%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/playback/PlaybackService.kt (94%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/playback/VolumeKeyDispatcher.kt (97%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/AlbumDetailScreen.kt (73%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/AlbumsScreen.kt (97%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/ArtImage.kt (92%) rename app/src/main/kotlin/ca/ksamad/{musicremote/ui/MusicRemoteApp.kt => encore/ui/EncoreApp.kt} (96%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/NowPlayingScreen.kt (93%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/PlayerViewModel.kt (87%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/QueueScreen.kt (52%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/SettingsScreen.kt (99%) rename app/src/main/kotlin/ca/ksamad/{musicremote => encore}/ui/SwipeActions.kt (99%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/ExampleUnitTest.kt (92%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdClientIntegrationTest.kt (98%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdConnectionTest.kt (99%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdProtocolTest.kt (97%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/mpd/MpdServerIntegrationTest.kt (92%) rename app/src/test/kotlin/ca/ksamad/{musicremote => encore}/mpd/model/MpdModelTest.kt (98%) diff --git a/README.md b/README.md index cc513be..93aea40 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Music Remote +# Encore A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and tested with **Nix**. @@ -20,7 +20,7 @@ app/ build.gradle.kts The :app module (Android + Compose config) src/main/ AndroidManifest.xml App/activity declaration - kotlin/ca/ksamad/musicremote/ + kotlin/ca/ksamad/encore/ MainActivity.kt Entry activity; sets the Compose content Theme.kt Material 3 theme (dynamic color) res/values/ strings.xml, themes.xml @@ -66,7 +66,7 @@ nix run .#emulate ## Reproducible Build with Nix ```sh -nix build # -> ./result/music-remote.apk +nix build # -> ./result/encore.apk ``` Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5aecf1..03573f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "ca.ksamad.musicremote" + namespace = "ca.ksamad.encore" compileSdk = 35 // Pin the build-tools to exactly what Nix supplies. Without this, AGP picks @@ -14,7 +14,7 @@ android { buildToolsVersion = "35.0.0" defaultConfig { - applicationId = "ca.ksamad.musicremote" + applicationId = "ca.ksamad.encore" minSdk = 24 targetSdk = 35 versionCode = 1 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 064e6b1..9decafc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,11 +10,11 @@ + android:theme="@style/Theme.Encore"> diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt b/app/src/main/kotlin/ca/ksamad/encore/EncoreApplication.kt similarity index 76% rename from app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt rename to app/src/main/kotlin/ca/ksamad/encore/EncoreApplication.kt index ce89333..5548874 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/EncoreApplication.kt @@ -1,8 +1,8 @@ -package ca.ksamad.musicremote +package ca.ksamad.encore import android.app.Application -import ca.ksamad.musicremote.playback.ArtImageLoader -import ca.ksamad.musicremote.playback.MpdConnectionManager +import ca.ksamad.encore.playback.ArtImageLoader +import ca.ksamad.encore.playback.MpdConnectionManager import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader @@ -10,12 +10,12 @@ import coil3.SingletonImageLoader /** * Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any * single Activity and can be shared with the foreground - * [ca.ksamad.musicremote.playback.PlaybackService]. + * [ca.ksamad.encore.playback.PlaybackService]. * * Also the Coil [SingletonImageLoader.Factory], wiring cover-art loading to that * same manager so `AsyncImage` calls anywhere in the app fetch (and cache) MPD art. */ -class MusicRemoteApplication : Application(), SingletonImageLoader.Factory { +class EncoreApplication : Application(), SingletonImageLoader.Factory { val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt b/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt similarity index 89% rename from app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt rename to app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt index 614aad4..2c87ef4 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote +package ca.ksamad.encore import android.Manifest import android.content.pm.PackageManager @@ -16,8 +16,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat -import ca.ksamad.musicremote.playback.VolumeKeyDispatcher -import ca.ksamad.musicremote.ui.MusicRemoteApp +import ca.ksamad.encore.playback.VolumeKeyDispatcher +import ca.ksamad.encore.ui.EncoreApp class MainActivity : ComponentActivity() { @@ -27,7 +27,7 @@ class MainActivity : ComponentActivity() { // Intercept the hardware volume keys while we're focused so they drive the // server volume without the system slider appearing (see VolumeKeyDispatcher). private val volumeKeys by lazy { - VolumeKeyDispatcher((application as MusicRemoteApplication).manager) + VolumeKeyDispatcher((application as EncoreApplication).manager) } override fun onCreate(savedInstanceState: Bundle?) { @@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() maybeRequestNotificationPermission() setContent { - MusicRemoteTheme { + EncoreTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Surface( modifier = Modifier @@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() { .padding(innerPadding), color = MaterialTheme.colorScheme.background, ) { - MusicRemoteApp() + EncoreApp() } } } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt b/app/src/main/kotlin/ca/ksamad/encore/Theme.kt similarity index 95% rename from app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt rename to app/src/main/kotlin/ca/ksamad/encore/Theme.kt index 2caeb81..0711e40 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/Theme.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote +package ca.ksamad.encore import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -17,7 +17,7 @@ import androidx.compose.ui.platform.LocalContext * wallpaper); on older versions it falls back to a default light/dark scheme. */ @Composable -fun MusicRemoteTheme( +fun EncoreTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt b/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt similarity index 90% rename from app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt rename to app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt index 52efb61..e16d026 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/ConnectionSettings.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.data +package ca.ksamad.encore.data /** The persisted MPD server the app connects to. */ data class ConnectionSettings( diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt similarity index 98% rename from app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt rename to app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt index 06a20e7..a1ea0bc 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.data +package ca.ksamad.encore.data import android.content.Context import androidx.datastore.core.DataStore diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt similarity index 97% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt index 8530267..f07c04e 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt @@ -1,10 +1,10 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd -import ca.ksamad.musicremote.mpd.model.MpdAlbum -import ca.ksamad.musicremote.mpd.model.MpdSong -import ca.ksamad.musicremote.mpd.model.MpdStatistics -import ca.ksamad.musicremote.mpd.model.MpdStatus -import ca.ksamad.musicremote.mpd.model.PlayerState +import ca.ksamad.encore.mpd.model.MpdAlbum +import ca.ksamad.encore.mpd.model.MpdSong +import ca.ksamad.encore.mpd.model.MpdStatistics +import ca.ksamad.encore.mpd.model.MpdStatus +import ca.ksamad.encore.mpd.model.PlayerState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -164,6 +164,12 @@ class MpdClient( suspend fun stop() = run(MpdCommands.stop()) suspend fun clearQueue() = run(MpdCommands.clear()) + /** Remove a queue entry by its stable song id. */ + suspend fun removeQueueItem(songId: Int) = run(MpdCommands.deleteId(songId)) + + /** Insert a single track at an absolute queue position (used to undo a removal). */ + suspend fun addTrackAt(uri: String, position: Int) = run(MpdCommands.add(uri, position.toString())) + /** Append a single track to the end of the queue; playback is untouched. */ suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri)) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt similarity index 99% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt index 7c4ca02..e95185a 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd /** * Typed builders for the command lines we send. These return the assembled, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnection.kt similarity index 99% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnection.kt index 1361ed5..d4b4acd 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnection.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd import java.io.BufferedInputStream import java.io.BufferedOutputStream diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnectionState.kt similarity index 94% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnectionState.kt index d069394..e4db5de 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdConnectionState.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd /** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */ sealed interface MpdConnectionState { diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdException.kt similarity index 98% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdException.kt index ef712e5..28638a1 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdException.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd import java.io.IOException diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdProtocol.kt similarity index 98% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdProtocol.kt index 329afda..5505c89 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdProtocol.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd /** * Pure, connection-independent helpers for speaking the MPD text protocol: diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdResponse.kt similarity index 98% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/MpdResponse.kt index 5f3659b..9477e9a 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdResponse.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd +package ca.ksamad.encore.mpd /** * The parsed result of a single successful command: the ordered `key: value` diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdAlbum.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdAlbum.kt similarity index 91% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdAlbum.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdAlbum.kt index 73ff95b..30c2f7d 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdAlbum.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdAlbum.kt @@ -1,6 +1,6 @@ -package ca.ksamad.musicremote.mpd.model +package ca.ksamad.encore.mpd.model -import ca.ksamad.musicremote.mpd.MpdResponse +import ca.ksamad.encore.mpd.MpdResponse /** An album in the library, optionally attributed to an album artist. */ data class MpdAlbum( diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt similarity index 97% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt index 49139d9..ab56a77 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd.model +package ca.ksamad.encore.mpd.model /** * A song/file entry, parsed from the metadata block MPD emits for `currentsong`, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatistics.kt similarity index 96% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatistics.kt index 5e31fe3..b8dd92e 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatistics.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd.model +package ca.ksamad.encore.mpd.model /** * Database/server statistics from a `stats` response. Durations are in seconds diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt similarity index 98% rename from app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt rename to app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt index 64262dd..328587d 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.mpd.model +package ca.ksamad.encore.mpd.model /** Player transport state as reported by the `state` field of `status`. */ enum class PlayerState { diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt similarity index 99% rename from app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt rename to app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt index 6ac8624..a7fd756 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.playback +package ca.ksamad.encore.playback import coil3.ImageLoader import coil3.PlatformContext diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt similarity index 93% rename from app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt rename to app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt index aadfcec..a43ad01 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.playback +package ca.ksamad.encore.playback /** * A request for a piece of MPD cover art, used as the model handed to Coil. The diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt similarity index 92% rename from app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt rename to app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt index 17e7df6..c48aed8 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt @@ -1,13 +1,13 @@ -package ca.ksamad.musicremote.playback +package ca.ksamad.encore.playback import android.content.Context -import ca.ksamad.musicremote.data.ConnectionSettings -import ca.ksamad.musicremote.data.SettingsRepository -import ca.ksamad.musicremote.mpd.MpdClient -import ca.ksamad.musicremote.mpd.MpdConnectionState -import ca.ksamad.musicremote.mpd.model.MpdAlbum -import ca.ksamad.musicremote.mpd.model.MpdSong -import ca.ksamad.musicremote.mpd.model.MpdStatistics +import ca.ksamad.encore.data.ConnectionSettings +import ca.ksamad.encore.data.SettingsRepository +import ca.ksamad.encore.mpd.MpdClient +import ca.ksamad.encore.mpd.MpdConnectionState +import ca.ksamad.encore.mpd.model.MpdAlbum +import ca.ksamad.encore.mpd.model.MpdSong +import ca.ksamad.encore.mpd.model.MpdStatistics import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -160,6 +160,12 @@ class MpdConnectionManager(context: Context) { /** Jump to a queue entry by its stable song id. */ fun playQueueItem(songId: Int) = fire { playId(songId) } + /** Remove a queue entry by its stable song id. */ + fun removeQueueItem(songId: Int) = fire { removeQueueItem(songId) } + + /** Re-insert a track at an absolute queue position (undo a removal). */ + fun addTrackAt(uri: String, position: Int) = fire { addTrackAt(uri, position) } + /** One-shot library fetch; returns empty on any failure. */ suspend fun loadAlbums(): List = runCatching { client.albums() }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt similarity index 94% rename from app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt rename to app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt index 5fb6727..ce62506 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.playback +package ca.ksamad.encore.playback import android.app.NotificationChannel import android.app.NotificationManager @@ -22,13 +22,13 @@ import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.toBitmap -import ca.ksamad.musicremote.MainActivity -import ca.ksamad.musicremote.MusicRemoteApplication -import ca.ksamad.musicremote.R -import ca.ksamad.musicremote.mpd.MpdConnectionState -import ca.ksamad.musicremote.mpd.model.MpdSong -import ca.ksamad.musicremote.mpd.model.MpdStatus -import ca.ksamad.musicremote.mpd.model.PlayerState +import ca.ksamad.encore.MainActivity +import ca.ksamad.encore.EncoreApplication +import ca.ksamad.encore.R +import ca.ksamad.encore.mpd.MpdConnectionState +import ca.ksamad.encore.mpd.model.MpdSong +import ca.ksamad.encore.mpd.model.MpdStatus +import ca.ksamad.encore.mpd.model.PlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -52,7 +52,7 @@ import kotlinx.coroutines.launch class PlaybackService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val manager get() = (application as MusicRemoteApplication).manager + private val manager get() = (application as EncoreApplication).manager private lateinit var session: MediaSessionCompat private lateinit var volumeProvider: VolumeProviderCompat @@ -62,7 +62,7 @@ class PlaybackService : Service() { override fun onCreate() { super.onCreate() - session = MediaSessionCompat(this, "MusicRemote").apply { + session = MediaSessionCompat(this, "Encore").apply { setCallback(mediaCallback) isActive = true } @@ -218,7 +218,7 @@ class PlaybackService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_music_note) - .setContentTitle(song?.title ?: song?.uri ?: "Music Remote") + .setContentTitle(song?.title ?: song?.uri ?: "Encore") .setContentText(song?.artist ?: "") .setSubText(song?.album) .setContentIntent(contentIntent) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt similarity index 97% rename from app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt rename to app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt index f327e68..29563c3 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/VolumeKeyDispatcher.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.playback +package ca.ksamad.encore.playback import android.view.KeyEvent diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt similarity index 73% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt index 6358dba..909f068 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,10 +40,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import ca.ksamad.musicremote.mpd.model.MpdAlbum -import ca.ksamad.musicremote.mpd.model.MpdSong -import ca.ksamad.musicremote.playback.AlbumArt -import ca.ksamad.musicremote.playback.SongArt +import ca.ksamad.encore.mpd.model.MpdAlbum +import ca.ksamad.encore.mpd.model.MpdSong +import ca.ksamad.encore.playback.AlbumArt import kotlinx.coroutines.launch /** @@ -100,6 +99,22 @@ fun AlbumDetailScreen( ) } + // One track row, wired to its swipe actions. Reused whether the list is + // flat or split into disc groups. + val trackItem: @Composable (MpdSong) -> Unit = { track -> + TrackRow( + track = track, + onQueue = { + vm.queueTrack(track.uri) + flash("Added “${track.title ?: track.uri}” to the queue") + }, + onPlayNext = { + vm.playTrackNext(track.uri) + flash("“${track.title ?: track.uri}” will play next") + }, + ) + } + val loaded = tracks when { loaded == null -> item { @@ -121,18 +136,15 @@ fun AlbumDetailScreen( } } - else -> items(loaded, key = { it.uri }) { track -> - TrackRow( - track = track, - onQueue = { - vm.queueTrack(track.uri) - flash("Added “${track.title ?: track.uri}” to the queue") - }, - onPlayNext = { - vm.playTrackNext(track.uri) - flash("“${track.title ?: track.uri}” will play next") - }, - ) + // Single disc (or untagged): a plain flat list. + loaded.map(::discNumberOf).distinct().size <= 1 -> + items(loaded, key = { it.uri }) { trackItem(it) } + + // Multi-disc: a light "Disc N" header before each disc's tracks. The + // list arrives already sorted by (disc, track), so groupBy keeps order. + else -> loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) -> + item(key = "disc-$disc") { DiscHeader(disc) } + items(discTracks, key = { it.uri }) { trackItem(it) } } } } @@ -190,7 +202,9 @@ private fun AlbumDetailHeader( /** * A single track row. Swiping right queues the track, swiping left plays it next - * — the same gesture as the album library, scoped to this one track. + * — the same gesture as the album library, scoped to this one track. The leading + * slot shows the track number (all tracks share the album's cover, so a per-track + * thumbnail would be redundant here). */ @Composable private fun TrackRow( @@ -201,11 +215,13 @@ private fun TrackRow( QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) { ListItem( leadingContent = { - ArtImage( - model = SongArt(track.uri), - iconSize = 18.dp, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), - ) + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + Text( + trackNumberOf(track)?.toString() ?: "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } }, headlineContent = { Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -217,3 +233,22 @@ private fun TrackRow( ) } } + +/** A light section header separating discs in a multi-disc album. */ +@Composable +private fun DiscHeader(disc: Int) { + Text( + "Disc $disc", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} + +/** Leading track number from a `Track` tag (`"3"`, `"3/12"`, …), or null if absent. */ +private fun trackNumberOf(track: MpdSong): Int? = + track.track?.takeWhile { it.isDigit() }?.toIntOrNull() + +/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */ +private fun discNumberOf(track: MpdSong): Int = + track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1 diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt similarity index 97% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt index 44af066ffdc9ff2eb7a2eb3ad6b957013526a3e6..e26d648a6f884eba97996b5943a9b623ff46677c 100644 GIT binary patch delta 60 zcmeD3`t2!RkeHmEn4YSToT!&woS2)KqL-SNoL{t2<_-rdm^GP+Qw_q}Y{bbez^Jg< IPVx*l0Hih)d;kCd delta 87 zcmezE+2y5JkeHmEn4YSToT!&woS2)KqL*7*oS9sdnwwves#luHHTeUp( ) @Composable -fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { +fun EncoreApp(vm: PlayerViewModel = viewModel()) { val screen by vm.screen.collectAsStateWithLifecycle() var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) } // When set (within the Albums overlay), the album detail screen is shown. @@ -125,7 +125,7 @@ private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error: horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text("Music Remote", style = MaterialTheme.typography.headlineMedium) + Text("Encore", style = MaterialTheme.typography.headlineMedium) Text( "Connect to your MPD server", style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt similarity index 93% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt index ff65c7e..0e07b27 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui import androidx.compose.foundation.background import androidx.compose.foundation.rememberScrollState @@ -49,9 +49,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import ca.ksamad.musicremote.mpd.model.MpdStatus -import ca.ksamad.musicremote.playback.SongArt -import ca.ksamad.musicremote.mpd.model.PlayerState +import ca.ksamad.encore.mpd.model.MpdStatus +import ca.ksamad.encore.playback.SongArt +import ca.ksamad.encore.mpd.model.PlayerState import kotlinx.coroutines.delay /** @@ -125,6 +125,22 @@ fun NowPlayingScreen( overflow = TextOverflow.Ellipsis, ) + // Descriptive metadata (release year · genre), when the tags are present. + val descriptors = listOfNotNull( + song?.date?.let(::releaseYear), + song?.genre?.takeIf { it.isNotBlank() }, + ) + if (descriptors.isNotEmpty()) { + Text( + text = descriptors.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + AudioPropertyPills(status = status) Spacer(Modifier.height(32.dp)) @@ -380,6 +396,10 @@ private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) { } } +/** The 4-digit year from an MPD `Date` tag (`"2019"`, `"2019-05-03"`, …), or null. */ +private fun releaseYear(date: String): String? = + Regex("\\d{4}").find(date)?.value + private fun formatTime(seconds: Double): String { val total = seconds.toInt().coerceAtLeast(0) val m = total / 60 diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt similarity index 87% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt index a38d4bc..1176c94 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt @@ -1,11 +1,11 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import ca.ksamad.musicremote.MusicRemoteApplication -import ca.ksamad.musicremote.data.ConnectionSettings -import ca.ksamad.musicremote.mpd.MpdConnectionState +import ca.ksamad.encore.EncoreApplication +import ca.ksamad.encore.data.ConnectionSettings +import ca.ksamad.encore.mpd.MpdConnectionState import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -25,14 +25,14 @@ sealed interface AppScreen { /** * Thin UI-facing layer over the app-scoped - * [ca.ksamad.musicremote.playback.MpdConnectionManager]: it exposes the manager's + * [ca.ksamad.encore.playback.MpdConnectionManager]: it exposes the manager's * flows to Compose and derives the top-level [AppScreen]. The connection itself * lives in the manager (shared with the foreground service), so it survives this * ViewModel being cleared on Activity recreation. */ class PlayerViewModel(application: Application) : AndroidViewModel(application) { - private val manager = (application as MusicRemoteApplication).manager + private val manager = (application as EncoreApplication).manager val status = manager.status val currentSong = manager.currentSong @@ -79,6 +79,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) suspend fun loadAlbums() = manager.loadAlbums() fun playQueueItem(songId: Int) = manager.playQueueItem(songId) + fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId) + fun addTrackAt(uri: String, position: Int) = manager.addTrackAt(uri, position) fun clearQueue() = manager.clearQueue() suspend fun loadStatistics() = manager.loadStatistics() suspend fun loadQueue() = manager.loadQueue() diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt similarity index 52% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt index 2a75cdc..c900301 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt @@ -1,5 +1,6 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -11,6 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.AlertDialog @@ -22,14 +24,22 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,9 +48,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import ca.ksamad.musicremote.mpd.model.MpdSong -import ca.ksamad.musicremote.mpd.model.MpdStatus -import ca.ksamad.musicremote.playback.SongArt +import ca.ksamad.encore.mpd.model.MpdSong +import ca.ksamad.encore.mpd.model.MpdStatus +import ca.ksamad.encore.playback.SongArt +import kotlinx.coroutines.launch /** * "Up next": the tracks that will auto-play from the current one to the end of @@ -62,10 +73,16 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { value = vm.loadQueue() } + // Ids swiped away locally, hidden immediately so the row leaves without waiting + // for the server round-trip. Reset on every reload — by then the fresh queue + // already reflects the change (or, if the delete failed, restores the track). + var pendingRemoval by remember(fullQueue) { mutableStateOf(emptySet()) } + // Slice from the current song to the end. `song` is the current queue index. val currentPos = status?.song val upcoming: List? = fullQueue?.let { q -> - if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q + val slice = if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q + if (pendingRemoval.isEmpty()) slice else slice.filter { it.id !in pendingRemoval } } val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true) @@ -73,6 +90,28 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { var confirmClear by remember { mutableStateOf(false) } val queueNotEmpty = fullQueue?.isNotEmpty() == true + // Undo affordance for swipe-to-remove. + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + fun removeSong(song: MpdSong) { + val id = song.id ?: return + pendingRemoval = pendingRemoval + id + vm.removeQueueItem(id) + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { + val result = snackbarHostState.showSnackbar( + message = "Removed “${song.title ?: song.uri}”", + actionLabel = "Undo", + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + // Re-insert at its original index; fall back to append if unknown. + song.pos?.let { vm.addTrackAt(song.uri, it) } ?: vm.queueTrack(song.uri) + pendingRemoval = pendingRemoval - id + } + } + } + Scaffold( topBar = { TopAppBar( @@ -91,6 +130,7 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> when { upcoming == null -> Box( @@ -119,37 +159,16 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { items(upcoming, key = { it.id ?: it.uri }) { song -> // First item is the currently-playing track (when there is one). val isCurrent = hasCurrent && song === upcoming.first() - ListItem( - modifier = Modifier.clickable { song.id?.let { vm.playQueueItem(it) } }, - colors = if (isCurrent) { - ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) - } else { - ListItemDefaults.colors() - }, - leadingContent = { - ArtImage( - model = SongArt(song.uri), - iconSize = 20.dp, - modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), - ) - }, - headlineContent = { - Text( - song.title ?: song.uri, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, - ) - }, - supportingContent = song.artist?.let { - { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } - }, - trailingContent = if (isCurrent) { - { Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") } - } else { - song.duration?.let { { Text(formatDuration(it)) } } - }, - ) + val id = song.id + // The current track and id-less entries aren't swipe-removable — + // removing the current one would disrupt playback. + if (isCurrent || id == null) { + QueueRow(song, isCurrent, onClick = { id?.let { vm.playQueueItem(it) } }) + } else { + SwipeToRemoveRow(onRemove = { removeSong(song) }) { + QueueRow(song, isCurrent = false, onClick = { vm.playQueueItem(id) }) + } + } } } } @@ -173,6 +192,96 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { } } +/** A single queue entry — art, title/artist, and a duration or now-playing badge. */ +@Composable +private fun QueueRow(song: MpdSong, isCurrent: Boolean, onClick: () -> Unit) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + colors = if (isCurrent) { + ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) + } else { + ListItemDefaults.colors() + }, + leadingContent = { + ArtImage( + model = SongArt(song.uri), + iconSize = 20.dp, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), + ) + }, + headlineContent = { + Text( + song.title ?: song.uri, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + ) + }, + supportingContent = song.artist?.let { + { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } + }, + trailingContent = if (isCurrent) { + { Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") } + } else { + song.duration?.let { { Text(formatDuration(it)) } } + }, + ) +} + +/** + * Wraps [content] in a swipe-to-remove gesture: a swipe in either direction + * settles the row off-screen and calls [onRemove]. The reveal is a red trash + * background on whichever edge is being swiped from. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeToRemoveRow(onRemove: () -> Unit, content: @Composable () -> Unit) { + val state = rememberSwipeToDismissBoxState( + confirmValueChange = { target -> + if (target != SwipeToDismissBoxValue.Settled) { + onRemove() + true // Commit the dismiss; the row is also filtered from the list. + } else { + false + } + }, + ) + SwipeToDismissBox( + state = state, + backgroundContent = { RemoveBackground(state.dismissDirection) }, + ) { + content() + } +} + +/** Red "delete" reveal behind a swiping queue row; empty while settled. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RemoveBackground(direction: SwipeToDismissBoxValue) { + if (direction == SwipeToDismissBoxValue.Settled) { + Box(Modifier.fillMaxSize()) + return + } + val alignment = if (direction == SwipeToDismissBoxValue.StartToEnd) { + Alignment.CenterStart + } else { + Alignment.CenterEnd + } + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 24.dp), + contentAlignment = alignment, + ) { + Icon( + Icons.Filled.Delete, + contentDescription = "Remove from queue", + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + /** Explains what will actually happen at the end of the list, given the modes. */ private fun playbackModeCaption(status: MpdStatus?): String? = when { status == null -> null diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt similarity index 99% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt index e368f0b..92ae090 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -36,7 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import ca.ksamad.musicremote.mpd.model.MpdStatistics +import ca.ksamad.encore.mpd.model.MpdStatistics import java.text.SimpleDateFormat import java.util.Date import java.util.Locale diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt similarity index 99% rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt rename to app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt index 10b79e6..029ca0e 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt @@ -1,4 +1,4 @@ -package ca.ksamad.musicremote.ui +package ca.ksamad.encore.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0343ca7..3b4eade 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - Music Remote + Encore diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index af991e0..2504151 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,5 +5,5 @@ Using a platform parent avoids pulling in the extra Material Components XML library that we don't need for a Compose-only app. --> -