chore: rename package from music-remote to encore.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Music Remote
|
# Encore
|
||||||
|
|
||||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||||
tested with **Nix**.
|
tested with **Nix**.
|
||||||
@@ -20,7 +20,7 @@ app/
|
|||||||
build.gradle.kts The :app module (Android + Compose config)
|
build.gradle.kts The :app module (Android + Compose config)
|
||||||
src/main/
|
src/main/
|
||||||
AndroidManifest.xml App/activity declaration
|
AndroidManifest.xml App/activity declaration
|
||||||
kotlin/ca/ksamad/musicremote/
|
kotlin/ca/ksamad/encore/
|
||||||
MainActivity.kt Entry activity; sets the Compose content
|
MainActivity.kt Entry activity; sets the Compose content
|
||||||
Theme.kt Material 3 theme (dynamic color)
|
Theme.kt Material 3 theme (dynamic color)
|
||||||
res/values/ strings.xml, themes.xml
|
res/values/ strings.xml, themes.xml
|
||||||
@@ -66,7 +66,7 @@ nix run .#emulate
|
|||||||
## Reproducible Build with Nix
|
## Reproducible Build with Nix
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix build # -> ./result/music-remote.apk
|
nix build # -> ./result/encore.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
|
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "ca.ksamad.musicremote"
|
namespace = "ca.ksamad.encore"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
|
||||||
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
|
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
|
||||||
@@ -14,7 +14,7 @@ android {
|
|||||||
buildToolsVersion = "35.0.0"
|
buildToolsVersion = "35.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "ca.ksamad.musicremote"
|
applicationId = "ca.ksamad.encore"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MusicRemoteApplication"
|
android:name=".EncoreApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.MusicRemote">
|
android:theme="@style/Theme.Encore">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
package ca.ksamad.musicremote
|
package ca.ksamad.encore
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import ca.ksamad.musicremote.playback.ArtImageLoader
|
import ca.ksamad.encore.playback.ArtImageLoader
|
||||||
import ca.ksamad.musicremote.playback.MpdConnectionManager
|
import ca.ksamad.encore.playback.MpdConnectionManager
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.PlatformContext
|
import coil3.PlatformContext
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
@@ -10,12 +10,12 @@ import coil3.SingletonImageLoader
|
|||||||
/**
|
/**
|
||||||
* Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any
|
* Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any
|
||||||
* single Activity and can be shared with the foreground
|
* 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
|
* 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.
|
* 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) }
|
val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) }
|
||||||
|
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote
|
package ca.ksamad.encore
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -16,8 +16,8 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import ca.ksamad.musicremote.playback.VolumeKeyDispatcher
|
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
||||||
import ca.ksamad.musicremote.ui.MusicRemoteApp
|
import ca.ksamad.encore.ui.EncoreApp
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Intercept the hardware volume keys while we're focused so they drive the
|
// Intercept the hardware volume keys while we're focused so they drive the
|
||||||
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
||||||
private val volumeKeys by lazy {
|
private val volumeKeys by lazy {
|
||||||
VolumeKeyDispatcher((application as MusicRemoteApplication).manager)
|
VolumeKeyDispatcher((application as EncoreApplication).manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
maybeRequestNotificationPermission()
|
maybeRequestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
MusicRemoteTheme {
|
EncoreTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
MusicRemoteApp()
|
EncoreApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote
|
package ca.ksamad.encore
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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.
|
* wallpaper); on older versions it falls back to a default light/dark scheme.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MusicRemoteTheme(
|
fun EncoreTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.data
|
package ca.ksamad.encore.data
|
||||||
|
|
||||||
/** The persisted MPD server the app connects to. */
|
/** The persisted MPD server the app connects to. */
|
||||||
data class ConnectionSettings(
|
data class ConnectionSettings(
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.data
|
package ca.ksamad.encore.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
+12
-6
@@ -1,10 +1,10 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
import ca.ksamad.encore.mpd.model.PlayerState
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -164,6 +164,12 @@ class MpdClient(
|
|||||||
suspend fun stop() = run(MpdCommands.stop())
|
suspend fun stop() = run(MpdCommands.stop())
|
||||||
suspend fun clearQueue() = run(MpdCommands.clear())
|
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. */
|
/** Append a single track to the end of the queue; playback is untouched. */
|
||||||
suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))
|
suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))
|
||||||
|
|
||||||
+1
-1
@@ -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,
|
* Typed builders for the command lines we send. These return the assembled,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
+1
-1
@@ -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. */
|
/** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */
|
||||||
sealed interface MpdConnectionState {
|
sealed interface MpdConnectionState {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure, connection-independent helpers for speaking the MPD text protocol:
|
* Pure, connection-independent helpers for speaking the MPD text protocol:
|
||||||
+1
-1
@@ -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`
|
* The parsed result of a single successful command: the ordered `key: value`
|
||||||
+2
-2
@@ -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. */
|
/** An album in the library, optionally attributed to an album artist. */
|
||||||
data class MpdAlbum(
|
data class MpdAlbum(
|
||||||
+1
-1
@@ -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`,
|
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`,
|
||||||
+1
-1
@@ -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
|
* Database/server statistics from a `stats` response. Durations are in seconds
|
||||||
+1
-1
@@ -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`. */
|
/** Player transport state as reported by the `state` field of `status`. */
|
||||||
enum class PlayerState {
|
enum class PlayerState {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.playback
|
package ca.ksamad.encore.playback
|
||||||
|
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.PlatformContext
|
import coil3.PlatformContext
|
||||||
+1
-1
@@ -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
|
* A request for a piece of MPD cover art, used as the model handed to Coil. The
|
||||||
+14
-8
@@ -1,13 +1,13 @@
|
|||||||
package ca.ksamad.musicremote.playback
|
package ca.ksamad.encore.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
import ca.ksamad.encore.data.ConnectionSettings
|
||||||
import ca.ksamad.musicremote.data.SettingsRepository
|
import ca.ksamad.encore.data.SettingsRepository
|
||||||
import ca.ksamad.musicremote.mpd.MpdClient
|
import ca.ksamad.encore.mpd.MpdClient
|
||||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -160,6 +160,12 @@ class MpdConnectionManager(context: Context) {
|
|||||||
/** Jump to a queue entry by its stable song id. */
|
/** Jump to a queue entry by its stable song id. */
|
||||||
fun playQueueItem(songId: Int) = fire { playId(songId) }
|
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. */
|
/** One-shot library fetch; returns empty on any failure. */
|
||||||
suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList())
|
suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList())
|
||||||
|
|
||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.playback
|
package ca.ksamad.encore.playback
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -22,13 +22,13 @@ import coil3.SingletonImageLoader
|
|||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.SuccessResult
|
import coil3.request.SuccessResult
|
||||||
import coil3.toBitmap
|
import coil3.toBitmap
|
||||||
import ca.ksamad.musicremote.MainActivity
|
import ca.ksamad.encore.MainActivity
|
||||||
import ca.ksamad.musicremote.MusicRemoteApplication
|
import ca.ksamad.encore.EncoreApplication
|
||||||
import ca.ksamad.musicremote.R
|
import ca.ksamad.encore.R
|
||||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
import ca.ksamad.encore.mpd.model.PlayerState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -52,7 +52,7 @@ import kotlinx.coroutines.launch
|
|||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
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 session: MediaSessionCompat
|
||||||
private lateinit var volumeProvider: VolumeProviderCompat
|
private lateinit var volumeProvider: VolumeProviderCompat
|
||||||
@@ -62,7 +62,7 @@ class PlaybackService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
session = MediaSessionCompat(this, "MusicRemote").apply {
|
session = MediaSessionCompat(this, "Encore").apply {
|
||||||
setCallback(mediaCallback)
|
setCallback(mediaCallback)
|
||||||
isActive = true
|
isActive = true
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ class PlaybackService : Service() {
|
|||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_music_note)
|
.setSmallIcon(R.drawable.ic_music_note)
|
||||||
.setContentTitle(song?.title ?: song?.uri ?: "Music Remote")
|
.setContentTitle(song?.title ?: song?.uri ?: "Encore")
|
||||||
.setContentText(song?.artist ?: "")
|
.setContentText(song?.artist ?: "")
|
||||||
.setSubText(song?.album)
|
.setSubText(song?.album)
|
||||||
.setContentIntent(contentIntent)
|
.setContentIntent(contentIntent)
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.playback
|
package ca.ksamad.encore.playback
|
||||||
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
|
||||||
+57
-22
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.draw.clip
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.playback.AlbumArt
|
import ca.ksamad.encore.playback.AlbumArt
|
||||||
import ca.ksamad.musicremote.playback.SongArt
|
|
||||||
import kotlinx.coroutines.launch
|
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
|
val loaded = tracks
|
||||||
when {
|
when {
|
||||||
loaded == null -> item {
|
loaded == null -> item {
|
||||||
@@ -121,18 +136,15 @@ fun AlbumDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> items(loaded, key = { it.uri }) { track ->
|
// Single disc (or untagged): a plain flat list.
|
||||||
TrackRow(
|
loaded.map(::discNumberOf).distinct().size <= 1 ->
|
||||||
track = track,
|
items(loaded, key = { it.uri }) { trackItem(it) }
|
||||||
onQueue = {
|
|
||||||
vm.queueTrack(track.uri)
|
// Multi-disc: a light "Disc N" header before each disc's tracks. The
|
||||||
flash("Added “${track.title ?: track.uri}” to the queue")
|
// list arrives already sorted by (disc, track), so groupBy keeps order.
|
||||||
},
|
else -> loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) ->
|
||||||
onPlayNext = {
|
item(key = "disc-$disc") { DiscHeader(disc) }
|
||||||
vm.playTrackNext(track.uri)
|
items(discTracks, key = { it.uri }) { trackItem(it) }
|
||||||
flash("“${track.title ?: track.uri}” will play next")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +202,9 @@ private fun AlbumDetailHeader(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A single track row. Swiping right queues the track, swiping left plays it next
|
* 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
|
@Composable
|
||||||
private fun TrackRow(
|
private fun TrackRow(
|
||||||
@@ -201,11 +215,13 @@ private fun TrackRow(
|
|||||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||||
ListItem(
|
ListItem(
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
ArtImage(
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
model = SongArt(track.uri),
|
Text(
|
||||||
iconSize = 18.dp,
|
trackNumberOf(track)?.toString() ?: "•",
|
||||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
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
|
||||||
BIN
Binary file not shown.
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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
|
* 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
|
* 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
|
* 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
|
@Composable
|
||||||
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
|
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
|
||||||
+5
-5
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
import ca.ksamad.encore.data.ConnectionSettings
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
|
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
|
||||||
@@ -43,7 +43,7 @@ private val AlbumSaver = listSaver<MpdAlbum?, String?>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||||
val screen by vm.screen.collectAsStateWithLifecycle()
|
val screen by vm.screen.collectAsStateWithLifecycle()
|
||||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||||
// When set (within the Albums overlay), the album detail screen is shown.
|
// 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,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("Music Remote", style = MaterialTheme.typography.headlineMedium)
|
Text("Encore", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
"Connect to your MPD server",
|
"Connect to your MPD server",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
+24
-4
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.musicremote.playback.SongArt
|
import ca.ksamad.encore.playback.SongArt
|
||||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
import ca.ksamad.encore.mpd.model.PlayerState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +125,22 @@ fun NowPlayingScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
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)
|
AudioPropertyPills(status = status)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
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 {
|
private fun formatTime(seconds: Double): String {
|
||||||
val total = seconds.toInt().coerceAtLeast(0)
|
val total = seconds.toInt().coerceAtLeast(0)
|
||||||
val m = total / 60
|
val m = total / 60
|
||||||
+8
-6
@@ -1,11 +1,11 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import ca.ksamad.musicremote.MusicRemoteApplication
|
import ca.ksamad.encore.EncoreApplication
|
||||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
import ca.ksamad.encore.data.ConnectionSettings
|
||||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -25,14 +25,14 @@ sealed interface AppScreen {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin UI-facing layer over the app-scoped
|
* 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
|
* 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
|
* lives in the manager (shared with the foreground service), so it survives this
|
||||||
* ViewModel being cleared on Activity recreation.
|
* ViewModel being cleared on Activity recreation.
|
||||||
*/
|
*/
|
||||||
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
|
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 status = manager.status
|
||||||
val currentSong = manager.currentSong
|
val currentSong = manager.currentSong
|
||||||
@@ -79,6 +79,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
suspend fun loadAlbums() = manager.loadAlbums()
|
suspend fun loadAlbums() = manager.loadAlbums()
|
||||||
|
|
||||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
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()
|
fun clearQueue() = manager.clearQueue()
|
||||||
suspend fun loadStatistics() = manager.loadStatistics()
|
suspend fun loadStatistics() = manager.loadStatistics()
|
||||||
suspend fun loadQueue() = manager.loadQueue()
|
suspend fun loadQueue() = manager.loadQueue()
|
||||||
+129
-20
@@ -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.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.DeleteSweep
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -22,14 +24,22 @@ import androidx.compose.material3.ListItem
|
|||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.musicremote.playback.SongArt
|
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
|
* "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()
|
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<Int>()) }
|
||||||
|
|
||||||
// Slice from the current song to the end. `song` is the current queue index.
|
// Slice from the current song to the end. `song` is the current queue index.
|
||||||
val currentPos = status?.song
|
val currentPos = status?.song
|
||||||
val upcoming: List<MpdSong>? = fullQueue?.let { q ->
|
val upcoming: List<MpdSong>? = 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)
|
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) }
|
var confirmClear by remember { mutableStateOf(false) }
|
||||||
val queueNotEmpty = fullQueue?.isNotEmpty() == true
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -91,6 +130,7 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when {
|
when {
|
||||||
upcoming == null -> Box(
|
upcoming == null -> Box(
|
||||||
@@ -119,8 +159,44 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
|||||||
items(upcoming, key = { it.id ?: it.uri }) { song ->
|
items(upcoming, key = { it.id ?: it.uri }) { song ->
|
||||||
// First item is the currently-playing track (when there is one).
|
// First item is the currently-playing track (when there is one).
|
||||||
val isCurrent = hasCurrent && song === upcoming.first()
|
val isCurrent = hasCurrent && song === upcoming.first()
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmClear) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmClear = false },
|
||||||
|
title = { Text("Clear the queue?") },
|
||||||
|
text = { Text("This removes every track from the queue and stops playback.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
vm.clearQueue()
|
||||||
|
confirmClear = false
|
||||||
|
}) { Text("Clear") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmClear = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
ListItem(
|
||||||
modifier = Modifier.clickable { song.id?.let { vm.playQueueItem(it) } },
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
colors = if (isCurrent) {
|
colors = if (isCurrent) {
|
||||||
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||||
} else {
|
} else {
|
||||||
@@ -151,24 +227,57 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmClear) {
|
/** Red "delete" reveal behind a swiping queue row; empty while settled. */
|
||||||
AlertDialog(
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
onDismissRequest = { confirmClear = false },
|
@Composable
|
||||||
title = { Text("Clear the queue?") },
|
private fun RemoveBackground(direction: SwipeToDismissBoxValue) {
|
||||||
text = { Text("This removes every track from the queue and stops playback.") },
|
if (direction == SwipeToDismissBoxValue.Settled) {
|
||||||
confirmButton = {
|
Box(Modifier.fillMaxSize())
|
||||||
TextButton(onClick = {
|
return
|
||||||
vm.clearQueue()
|
}
|
||||||
confirmClear = false
|
val alignment = if (direction == SwipeToDismissBoxValue.StartToEnd) {
|
||||||
}) { Text("Clear") }
|
Alignment.CenterStart
|
||||||
},
|
} else {
|
||||||
dismissButton = {
|
Alignment.CenterEnd
|
||||||
TextButton(onClick = { confirmClear = false }) { Text("Cancel") }
|
}
|
||||||
},
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -36,7 +36,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Music Remote</string>
|
<string name="app_name">Encore</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
Using a platform parent avoids pulling in the extra Material Components
|
Using a platform parent avoids pulling in the extra Material Components
|
||||||
XML library that we don't need for a Compose-only app.
|
XML library that we don't need for a Compose-only app.
|
||||||
-->
|
-->
|
||||||
<style name="Theme.MusicRemote" parent="@android:style/Theme.Material.Light.NoActionBar" />
|
<style name="Theme.Encore" parent="@android:style/Theme.Material.Light.NoActionBar" />
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote
|
package ca.ksamad.encore
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
package ca.ksamad.musicremote.mpd
|
package ca.ksamad.encore.mpd
|
||||||
|
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assume.assumeTrue
|
import org.junit.Assume.assumeTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
+2
-2
@@ -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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
+2
-2
@@ -49,7 +49,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
|
|||||||
|
|
||||||
- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
|
- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
|
||||||
clears + disconnects.
|
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
|
the player screen; a `LaunchedEffect` drops it when leaving the player) — no
|
||||||
Navigation-Compose dependency yet.
|
Navigation-Compose dependency yet.
|
||||||
- **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`,
|
- **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:
|
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
|
owns the single `MpdClient`, so the connection survives Activity recreation and
|
||||||
runs while the service is up. `PlayerViewModel` is a thin delegate over it.
|
runs while the service is up. `PlayerViewModel` is a thin delegate over it.
|
||||||
- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
|
- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ From the repo root, inside `nix develop`:
|
|||||||
```sh
|
```sh
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
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
|
`-r` reinstalls over the existing app **keeping its data** (so persisted
|
||||||
connection settings survive). To simulate a clean first run:
|
connection settings survive). To simulate a clean first run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
adb shell pm clear ca.ksamad.musicremote
|
adb shell pm clear ca.ksamad.encore
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|||||||
@@ -42,12 +42,12 @@
|
|||||||
musicRemote = pkgs.callPackage ./package.nix {
|
musicRemote = pkgs.callPackage ./package.nix {
|
||||||
inherit androidSdk androidHome buildToolsVersion;
|
inherit androidSdk androidHome buildToolsVersion;
|
||||||
};
|
};
|
||||||
applicationId = "ca.ksamad.musicremote";
|
applicationId = "ca.ksamad.encore";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
formatter = pkgs.nixfmt-rfc-style;
|
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;
|
packages.default = musicRemote;
|
||||||
|
|
||||||
# `nix develop` -> command-line dev shell (gradle + SDK wired up).
|
# `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";
|
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2";
|
||||||
|
|
||||||
shellHook = ''
|
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
|
# app on it. `app` points at the built package directory (the script globs
|
||||||
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
|
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
|
||||||
packages.emulate = pkgs.androidenv.emulateApp {
|
packages.emulate = pkgs.androidenv.emulateApp {
|
||||||
name = "emulate-MusicRemote";
|
name = "emulate-Encore";
|
||||||
platformVersion = "35";
|
platformVersion = "35";
|
||||||
abiVersion = "x86_64";
|
abiVersion = "x86_64";
|
||||||
# Bare AOSP image. This app only needs the base Android framework
|
# Bare AOSP image. This app only needs the base Android framework
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ kotlin.code.style=official
|
|||||||
|
|
||||||
# Generate per-module R classes (smaller, faster builds).
|
# Generate per-module R classes (smaller, faster builds).
|
||||||
android.nonTransitiveRClass=true
|
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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@
|
|||||||
}:
|
}:
|
||||||
|
|
||||||
stdenv.mkDerivation (finalAttrs: {
|
stdenv.mkDerivation (finalAttrs: {
|
||||||
pname = "music-remote";
|
pname = "encore";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
|
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -60,7 +60,7 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
|
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
|
||||||
"$out/music-remote.apk"
|
"$out/encore.apk"
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "MusicRemote"
|
rootProject.name = "Encore"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
Reference in New Issue
Block a user