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 44af066..e26d648 100644
Binary files a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt and b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt differ
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/ArtImage.kt
similarity index 92%
rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt
rename to app/src/main/kotlin/ca/ksamad/encore/ui/ArtImage.kt
index e7dd749..4644301 100644
--- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt
+++ b/app/src/main/kotlin/ca/ksamad/encore/ui/ArtImage.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
@@ -19,7 +19,7 @@ import coil3.compose.AsyncImage
* Cover art with a disc-icon placeholder. Draws the placeholder underneath and
* lets the [AsyncImage] paint over it once (and if) art loads — so a missing
* cover, a still-loading fetch, and a solid image all look right. [model] is an
- * [ca.ksamad.musicremote.playback.MpdArtData] (or null to show just the placeholder).
+ * [ca.ksamad.encore.playback.MpdArtData] (or null to show just the placeholder).
*/
@Composable
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt
similarity index 96%
rename from app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt
rename to app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt
index 461bc8c..14b1784 100644
--- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt
+++ b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.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.Column
@@ -25,8 +25,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
-import ca.ksamad.musicremote.data.ConnectionSettings
-import ca.ksamad.musicremote.mpd.model.MpdAlbum
+import ca.ksamad.encore.data.ConnectionSettings
+import ca.ksamad.encore.mpd.model.MpdAlbum
/**
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
@@ -43,7 +43,7 @@ private val AlbumSaver = listSaver(
)
@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.
-->
-
+
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/ExampleUnitTest.kt b/app/src/test/kotlin/ca/ksamad/encore/ExampleUnitTest.kt
similarity index 92%
rename from app/src/test/kotlin/ca/ksamad/musicremote/ExampleUnitTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/ExampleUnitTest.kt
index 75d13cb..3c4a101 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/ExampleUnitTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/ExampleUnitTest.kt
@@ -1,4 +1,4 @@
-package ca.ksamad.musicremote
+package ca.ksamad.encore
import org.junit.Assert.assertEquals
import org.junit.Test
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdClientIntegrationTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdClientIntegrationTest.kt
similarity index 98%
rename from app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdClientIntegrationTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/mpd/MpdClientIntegrationTest.kt
index 6afb084..ea6b72a 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdClientIntegrationTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdClientIntegrationTest.kt
@@ -1,4 +1,4 @@
-package ca.ksamad.musicremote.mpd
+package ca.ksamad.encore.mpd
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdConnectionTest.kt
similarity index 99%
rename from app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/mpd/MpdConnectionTest.kt
index 4c51803..82b3beb 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdConnectionTest.kt
@@ -1,4 +1,4 @@
-package ca.ksamad.musicremote.mpd
+package ca.ksamad.encore.mpd
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdProtocolTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdProtocolTest.kt
similarity index 97%
rename from app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdProtocolTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/mpd/MpdProtocolTest.kt
index 996adaf..378cefe 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdProtocolTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdProtocolTest.kt
@@ -1,4 +1,4 @@
-package ca.ksamad.musicremote.mpd
+package ca.ksamad.encore.mpd
import org.junit.Assert.assertEquals
import org.junit.Test
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdServerIntegrationTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdServerIntegrationTest.kt
similarity index 92%
rename from app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdServerIntegrationTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/mpd/MpdServerIntegrationTest.kt
index 666f7cf..ae05c70 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdServerIntegrationTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/MpdServerIntegrationTest.kt
@@ -1,8 +1,8 @@
-package ca.ksamad.musicremote.mpd
+package ca.ksamad.encore.mpd
-import ca.ksamad.musicremote.mpd.model.MpdSong
-import ca.ksamad.musicremote.mpd.model.MpdStatistics
-import ca.ksamad.musicremote.mpd.model.MpdStatus
+import ca.ksamad.encore.mpd.model.MpdSong
+import ca.ksamad.encore.mpd.model.MpdStatistics
+import ca.ksamad.encore.mpd.model.MpdStatus
import org.junit.Assert.assertNotNull
import org.junit.Assume.assumeTrue
import org.junit.Test
diff --git a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/model/MpdModelTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt
similarity index 98%
rename from app/src/test/kotlin/ca/ksamad/musicremote/mpd/model/MpdModelTest.kt
rename to app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt
index 53b6aa6..e6a715b 100644
--- a/app/src/test/kotlin/ca/ksamad/musicremote/mpd/model/MpdModelTest.kt
+++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.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
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
diff --git a/docs/TODO.md b/docs/TODO.md
index 8c2d9a4..f3c6115 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -49,7 +49,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
clears + disconnects.
-- Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, only over
+- Navigation is lightweight state (`showSettings` in `EncoreApp`, only over
the player screen; a `LaunchedEffect` drops it when leaving the player) — no
Navigation-Compose dependency yet.
- **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`,
@@ -64,7 +64,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
Done — full MALP-style **OS integration**, not just in-app. Architecture:
-- **`MpdConnectionManager`** (app-scoped, held by `MusicRemoteApplication`) now
+- **`MpdConnectionManager`** (app-scoped, held by `EncoreApplication`) now
owns the single `MpdClient`, so the connection survives Activity recreation and
runs while the service is up. `PlayerViewModel` is a thin delegate over it.
- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
diff --git a/docs/device-testing.md b/docs/device-testing.md
index 0b46150..8f33b97 100644
--- a/docs/device-testing.md
+++ b/docs/device-testing.md
@@ -50,14 +50,14 @@ From the repo root, inside `nix develop`:
```sh
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
-adb shell am start -n ca.ksamad.musicremote/.MainActivity # optional auto-launch
+adb shell am start -n ca.ksamad.encore/.MainActivity # optional auto-launch
```
`-r` reinstalls over the existing app **keeping its data** (so persisted
connection settings survive). To simulate a clean first run:
```sh
-adb shell pm clear ca.ksamad.musicremote
+adb shell pm clear ca.ksamad.encore
```
## Screenshots
diff --git a/flake.nix b/flake.nix
index 2e6ee4e..a1f94c7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -42,12 +42,12 @@
musicRemote = pkgs.callPackage ./package.nix {
inherit androidSdk androidHome buildToolsVersion;
};
- applicationId = "ca.ksamad.musicremote";
+ applicationId = "ca.ksamad.encore";
in
{
formatter = pkgs.nixfmt-rfc-style;
- # `nix build` -> reproducible debug APK in ./result/music-remote.apk
+ # `nix build` -> reproducible debug APK in ./result/encore.apk
packages.default = musicRemote;
# `nix develop` -> command-line dev shell (gradle + SDK wired up).
@@ -66,7 +66,7 @@
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2";
shellHook = ''
- echo "music-remote dev shell — try: gradle assembleDebug"
+ echo "encore dev shell — try: gradle assembleDebug"
'';
};
@@ -79,7 +79,7 @@
# app on it. `app` points at the built package directory (the script globs
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
packages.emulate = pkgs.androidenv.emulateApp {
- name = "emulate-MusicRemote";
+ name = "emulate-Encore";
platformVersion = "35";
abiVersion = "x86_64";
# Bare AOSP image. This app only needs the base Android framework
diff --git a/gradle.properties b/gradle.properties
index bc7c6e3..a81fde8 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -9,3 +9,8 @@ kotlin.code.style=official
# Generate per-module R classes (smaller, faster builds).
android.nonTransitiveRClass=true
+
+# The Nix build (package.nix / flake.nix) sets android.aapt2FromMavenOverride to
+# point AGP at the NixOS-patched aapt2. AGP flags that option as experimental;
+# suppress the warning since the override is intentional and required here.
+android.suppressUnsupportedOptionWarnings=android.aapt2FromMavenOverride
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 19a6bde..aaaabb3 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/package.nix b/package.nix
index 03e75b3..b57c8cd 100644
--- a/package.nix
+++ b/package.nix
@@ -20,7 +20,7 @@
}:
stdenv.mkDerivation (finalAttrs: {
- pname = "music-remote";
+ pname = "encore";
version = "0.1.0";
src = ./.;
@@ -60,7 +60,7 @@ stdenv.mkDerivation (finalAttrs: {
installPhase = ''
runHook preInstall
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
- "$out/music-remote.apk"
+ "$out/encore.apk"
runHook postInstall
'';
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1f2083d..869b304 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -20,5 +20,5 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "MusicRemote"
+rootProject.name = "Encore"
include(":app")