feat: art images and queue

This commit is contained in:
2026-07-27 00:16:46 -04:00
parent f665ac14ae
commit 98a792e202
19 changed files with 769 additions and 26 deletions
@@ -1,13 +1,21 @@
package ca.ksamad.musicremote
import android.app.Application
import ca.ksamad.musicremote.playback.ArtImageLoader
import ca.ksamad.musicremote.playback.MpdConnectionManager
import coil3.ImageLoader
import coil3.PlatformContext
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].
* single Activity and can be shared with the foreground
* [ca.ksamad.musicremote.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() {
class MusicRemoteApplication : Application(), SingletonImageLoader.Factory {
val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) }
@@ -17,4 +25,7 @@ class MusicRemoteApplication : Application() {
// as soon as the process starts, not only when the UI first observes it.
manager
}
override fun newImageLoader(context: PlatformContext): ImageLoader =
ArtImageLoader.create(context, manager)
}
@@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.io.IOException
@@ -112,6 +114,9 @@ class MpdClient(
command.execute(MpdCommands.password(pw))
idle.execute(MpdCommands.password(pw))
}
// Bigger binary chunks → album art transfers in ~1 round-trip.
// Best-effort: ignore if the server rejects it (old MPD).
runCatching { command.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
commandConn = command
idleConn = idle
}
@@ -126,6 +131,7 @@ class MpdClient(
idleConn?.close()
commandConn?.close()
}
closeArtConnections()
idleConn = null
commandConn = null
if (_connectionState.value !is MpdConnectionState.Error) {
@@ -189,6 +195,43 @@ class MpdClient(
conn.execute(MpdCommands.play())
}
/** Cover art bytes for a specific song URI, or null if the server has none. */
suspend fun songArt(uri: String): ByteArray? = withArtConnection { conn -> readArt(conn, uri) }
/** Cover art bytes for an album (resolves a representative track first). */
suspend fun albumArt(album: String, albumArtist: String?): ByteArray? = withArtConnection { conn ->
val trackUri = conn.execute(MpdCommands.findFirstTrack(album, albumArtist))
.split().firstOrNull()?.get("file")
?: return@withArtConnection null
readArt(conn, trackUri)
}
/** Try folder cover (`albumart`), then embedded art (`readpicture`). */
private fun readArt(conn: MpdConnection, uri: String): ByteArray? =
readArtChunks(conn, embedded = false, uri) ?: readArtChunks(conn, embedded = true, uri)
/** Loop the chunked art protocol until the whole image (per `size:`) is read. */
private fun readArtChunks(conn: MpdConnection, embedded: Boolean, uri: String): ByteArray? {
val out = java.io.ByteArrayOutputStream()
var offset = 0
while (true) {
val resp = try {
conn.execute(
if (embedded) MpdCommands.readPicture(uri, offset) else MpdCommands.albumArt(uri, offset),
)
} catch (e: MpdAckException) {
return null // no such art
}
val chunk = resp.binary
if (chunk == null || chunk.isEmpty()) break
out.write(chunk)
offset += chunk.size
val size = resp["size"]?.toIntOrNull() ?: break
if (offset >= size) break
}
return if (out.size() > 0) out.toByteArray() else null
}
// --- Internals ----------------------------------------------------------
private suspend fun run(commandLine: String) {
@@ -214,6 +257,67 @@ class MpdClient(
}
}
// --- Art connection pool -----------------------------------------------
//
// Cover fetches run on their own small pool of connections rather than the
// command connection, so (a) many covers load in parallel while scrolling and
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
// to ART_POOL_SIZE (bounded by the semaphore), reused when idle, and dropped
// on any transport error (a stale/timed-out one just gets reopened).
private val artSemaphore = Semaphore(ART_POOL_SIZE)
private val artPoolLock = Any()
private val idleArtConnections = ArrayDeque<MpdConnection>()
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
artSemaphore.withPermit {
val conn = borrowArtConnection()
try {
val result = withContext(ioDispatcher) { block(conn) }
returnArtConnection(conn)
result
} catch (e: MpdAckException) {
returnArtConnection(conn) // command refused, but connection is healthy
throw e
} catch (e: Throwable) {
runCatching { conn.close() } // discard a broken connection
throw e
}
}
private suspend fun borrowArtConnection(): MpdConnection {
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }?.let { return it }
return withContext(ioDispatcher) {
val h = host ?: throw MpdConnectionException("not connected")
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
password?.let { conn.execute(MpdCommands.password(it)) }
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
conn
}
}
private fun returnArtConnection(conn: MpdConnection) {
val kept = synchronized(artPoolLock) {
if (idleArtConnections.size < ART_POOL_SIZE) {
idleArtConnections.addLast(conn)
true
} else {
false
}
}
if (!kept) runCatching { conn.close() }
}
/** Close and drop all pooled art connections (on disconnect/reconnect/teardown). */
private fun closeArtConnections() {
val toClose = synchronized(artPoolLock) {
val copy = idleArtConnections.toList()
idleArtConnections.clear()
copy
}
toClose.forEach { runCatching { it.close() } }
}
/** Re-read `status` and `currentsong` into the flows (single round-trip). */
private suspend fun refresh() {
val (status, song) = withCommand { conn ->
@@ -311,6 +415,7 @@ class MpdClient(
}
idleConn = null
commandConn = null
closeArtConnections()
}
private fun failAndClose(message: String) {
@@ -320,6 +425,8 @@ class MpdClient(
companion object {
private const val COMMAND_READ_TIMEOUT_MS = 10_000
private const val BINARY_LIMIT = 512 * 1024
private const val ART_POOL_SIZE = 4
private const val KEEPALIVE_INTERVAL_MS = 25_000L
private const val RECONNECT_BACKOFF_MS = 1_000L
private const val MAX_RECONNECT_ATTEMPTS = 5
@@ -79,4 +79,23 @@ object MpdCommands {
} else {
MpdProtocol.command("findadd", "album", album, "albumartist", albumArtist)
}
/** First track of an album — used to resolve a URI for album-art lookup. */
fun findFirstTrack(album: String, albumArtist: String?) =
if (albumArtist.isNullOrEmpty()) {
MpdProtocol.command("find", "album", album, "window", "0:1")
} else {
MpdProtocol.command("find", "album", album, "albumartist", albumArtist, "window", "0:1")
}
// --- Album art ----------------------------------------------------------
/** Folder cover art for [uri]'s directory, starting at [offset] (chunked binary). */
fun albumArt(uri: String, offset: Int) = MpdProtocol.command("albumart", uri, offset.toString())
/** Embedded picture from the file at [uri], starting at [offset] (chunked binary). */
fun readPicture(uri: String, offset: Int) = MpdProtocol.command("readpicture", uri, offset.toString())
/** Raise the per-response binary chunk size so art transfers in fewer round-trips. */
fun binaryLimit(bytes: Int) = MpdProtocol.command("binarylimit", bytes.toString())
}
@@ -52,7 +52,10 @@ class MpdAckException(
companion object {
// ACK [2@1] {play} Bad song index
private val PATTERN = Regex("""ACK \[(\d+)@(\d+)] \{([^}]*)} ?(.*)""")
// NB: `]` and `}` are escaped. The JVM regex engine tolerates them bare,
// but Android's ICU engine rejects an unescaped `]`/`}`, which would make
// this Regex fail to compile at class-init time (only on-device).
private val PATTERN = Regex("""ACK \[(\d+)@(\d+)\] \{([^}]*)\} ?(.*)""")
/**
* Parse a raw `ACK …` response line. Returns `null` if [line] is not a
@@ -0,0 +1,117 @@
package ca.ksamad.musicremote.playback
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.disk.DiskCache
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.key.Keyer
import coil3.request.Options
import okio.Buffer
import okio.FileSystem
import okio.Path.Companion.toOkioPath
/**
* Builds the Coil [ImageLoader] that serves MPD cover art. Art bytes come from
* [MpdConnectionManager.fetchArt] via a custom [Fetcher].
*
* Coil's disk cache is only auto-managed by its network fetchers, so a custom
* fetcher must read/write the [DiskCache] itself — otherwise art is only
* memory-cached and every cold start re-fetches from the server. [MpdArtFetcher]
* therefore checks the disk cache first, and write-through-caches misses, so each
* cover is fetched from the (tiny) server exactly once, ever.
*/
object ArtImageLoader {
fun create(context: PlatformContext, manager: MpdConnectionManager): ImageLoader =
ImageLoader.Builder(context)
.components {
add(MpdArtKeyer(), MpdArtData::class)
add(MpdArtFetcher.Factory { manager.fetchArt(it) }, MpdArtData::class)
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("mpd_art").toOkioPath())
.maxSizeBytes(64L * 1024 * 1024)
.build()
}
.build()
/** Stable cache key per art request; shared by the keyer and the disk cache. */
private fun cacheKey(data: MpdArtData): String = when (data) {
is SongArt -> "song:${data.uri}"
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
}
private class MpdArtKeyer : Keyer<MpdArtData> {
override fun key(data: MpdArtData, options: Options): String = cacheKey(data)
}
private class MpdArtFetcher(
private val data: MpdArtData,
private val diskCache: DiskCache?,
private val fetch: suspend (MpdArtData) -> ByteArray?,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val key = cacheKey(data)
// 1. Serve from disk cache if present (survives process death).
diskCache?.openSnapshot(key)?.let { snapshot ->
return sourceResult(snapshot, key, DataSource.DISK)
}
// 2. Miss → fetch from the server.
val bytes = fetch(data) ?: return null
// 3. Write-through to the disk cache, then serve from it.
writeToDiskCache(key, bytes)?.let { snapshot ->
return sourceResult(snapshot, key, DataSource.NETWORK)
}
// 4. Fallback (no disk cache / write failed): serve from memory.
return SourceFetchResult(
source = ImageSource(Buffer().apply { write(bytes) }, FileSystem.SYSTEM),
mimeType = null,
dataSource = DataSource.NETWORK,
)
}
private fun writeToDiskCache(key: String, bytes: ByteArray): DiskCache.Snapshot? {
val cache = diskCache ?: return null
val editor = cache.openEditor(key) ?: return null
return try {
cache.fileSystem.write(editor.data) { write(bytes) }
editor.commitAndOpenSnapshot()
} catch (e: Exception) {
editor.abort()
null
}
}
private fun sourceResult(
snapshot: DiskCache.Snapshot,
key: String,
dataSource: DataSource,
): SourceFetchResult = SourceFetchResult(
source = ImageSource(
file = snapshot.data,
fileSystem = diskCache!!.fileSystem,
diskCacheKey = key,
closeable = snapshot,
),
mimeType = null,
dataSource = dataSource,
)
class Factory(
private val fetch: suspend (MpdArtData) -> ByteArray?,
) : Fetcher.Factory<MpdArtData> {
override fun create(data: MpdArtData, options: Options, imageLoader: ImageLoader): Fetcher =
MpdArtFetcher(data, imageLoader.diskCache, fetch)
}
}
}
@@ -0,0 +1,14 @@
package ca.ksamad.musicremote.playback
/**
* A request for a piece of MPD cover art, used as the model handed to Coil. The
* concrete type doubles as the cache key (see the keyer in [ArtImageLoader]), so
* the same song/album resolves to the same cached image.
*/
sealed interface MpdArtData
/** Cover art for a specific song URI (used for now-playing and the notification). */
data class SongArt(val uri: String) : MpdArtData
/** Cover art for an album (a representative track is resolved server-side). */
data class AlbumArt(val album: String, val albumArtist: String?) : MpdArtData
@@ -6,6 +6,7 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -130,12 +131,27 @@ class MpdConnectionManager(context: Context) {
}
fun setRepeat(on: Boolean) = fire { setRepeat(on) }
fun setRandom(on: Boolean) = fire { setRandom(on) }
fun setConsume(on: Boolean) = fire { setConsume(on) }
fun playAlbum(album: String, albumArtist: String?) = fire { playAlbum(album, albumArtist) }
/** Jump to a queue entry by its stable song id. */
fun playQueueItem(songId: Int) = fire { playId(songId) }
/** One-shot library fetch; returns empty on any failure. */
suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList())
/** One-shot play-queue fetch; returns empty on any failure. */
suspend fun loadQueue(): List<MpdSong> = runCatching { client.queue() }.getOrDefault(emptyList())
/** Fetch cover-art bytes for Coil (null on miss/failure). */
suspend fun fetchArt(data: MpdArtData): ByteArray? = runCatching {
when (data) {
is SongArt -> client.songArt(data.uri)
is AlbumArt -> client.albumArt(data.album, data.albumArtist)
}
}.getOrNull()
/**
* True when we own the volume: connected to a server that exposes a mixer.
* Used to decide whether hardware volume keys drive the *server* (silently,
@@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
@@ -17,6 +18,10 @@ import androidx.core.content.ContextCompat
import androidx.media.VolumeProviderCompat
import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media.session.MediaButtonReceiver
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
@@ -28,7 +33,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
@@ -103,6 +111,10 @@ class PlaybackService : Service() {
override fun onSeekTo(pos: Long) = manager.seekTo(pos / 1000.0)
}
// Album art for the session/notification, cached against the song it's for.
private var artUri: String? = null
private var artBitmap: Bitmap? = null
private fun observeState() {
scope.launch {
combine(manager.status, manager.currentSong) { s, song -> s to song }
@@ -113,6 +125,21 @@ class PlaybackService : Service() {
postNotification()
}
}
// Fetch cover art on song change (via Coil, so it shares the app's cache)
// and re-publish the metadata/notification once it arrives.
scope.launch {
manager.currentSong
.map { it?.uri }
.distinctUntilChanged()
.collectLatest { uri ->
artUri = uri
artBitmap = if (uri != null) loadArtBitmap(uri) else null
if (artUri == uri) {
session.setMetadata(buildMetadata(manager.currentSong.value, manager.status.value))
postNotification()
}
}
}
scope.launch {
manager.connectionState.collect { st ->
if (st is MpdConnectionState.Disconnected || st is MpdConnectionState.Error) {
@@ -123,6 +150,13 @@ class PlaybackService : Service() {
}
}
private suspend fun loadArtBitmap(uri: String): Bitmap? {
val result = SingletonImageLoader.get(applicationContext).execute(
ImageRequest.Builder(applicationContext).data(SongArt(uri)).build(),
)
return (result as? SuccessResult)?.image?.toBitmap()
}
private fun buildMetadata(song: MpdSong?, status: MpdStatus?): MediaMetadataCompat =
MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, song?.title ?: song?.uri ?: "")
@@ -130,6 +164,9 @@ class PlaybackService : Service() {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song?.album ?: "")
val durationMs = ((song?.duration ?: status?.duration ?: 0.0) * 1000).toLong()
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs)
if (artBitmap != null && artUri == song?.uri) {
putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, artBitmap)
}
}.build()
private fun buildPlaybackState(status: MpdStatus?): PlaybackStateCompat {
@@ -4,11 +4,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.Album
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -23,9 +24,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
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.playback.AlbumArt
/**
* Browse every album on the server. Tapping one replaces the queue with that
@@ -75,7 +78,13 @@ fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onPlay: () -> Unit) {
onPlay()
},
leadingContent = {
Icon(Icons.Filled.Album, contentDescription = null)
ArtImage(
model = AlbumArt(album.name, album.albumArtist),
iconSize = 24.dp,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp)),
)
},
headlineContent = {
Text(album.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -0,0 +1,45 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Album
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
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).
*/
@Composable
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Album,
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (model != null) {
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}
@@ -32,7 +32,7 @@ import ca.ksamad.musicremote.data.ConnectionSettings
* (first run, after disconnect, or on error), or the now-playing screen.
*/
/** Sub-screens layered over the player (no nav library needed for this few). */
private enum class PlayerOverlay { None, Settings, Albums }
private enum class PlayerOverlay { None, Settings, Albums, Queue }
@Composable
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
@@ -55,10 +55,12 @@ fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
onBack = { overlay = PlayerOverlay.None },
onPlay = { overlay = PlayerOverlay.None },
)
PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
PlayerOverlay.None -> NowPlayingScreen(
vm,
onOpenSettings = { overlay = PlayerOverlay.Settings },
onOpenLibrary = { overlay = PlayerOverlay.Albums },
onOpenQueue = { overlay = PlayerOverlay.Queue },
)
}
}
@@ -1,5 +1,7 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause
@@ -37,11 +41,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 kotlinx.coroutines.delay
@@ -56,6 +62,7 @@ fun NowPlayingScreen(
vm: PlayerViewModel,
onOpenSettings: () -> Unit,
onOpenLibrary: () -> Unit,
onOpenQueue: () -> Unit,
) {
val status by vm.status.collectAsStateWithLifecycle()
val song by vm.currentSong.collectAsStateWithLifecycle()
@@ -64,11 +71,15 @@ fun NowPlayingScreen(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// --- Top bar: library + settings ------------------------------------
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onOpenQueue) {
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
}
IconButton(onClick = onOpenLibrary) {
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums")
}
@@ -77,6 +88,16 @@ fun NowPlayingScreen(
}
}
// --- Album art -------------------------------------------------------
ArtImage(
model = song?.uri?.let { SongArt(it) },
iconSize = 96.dp,
modifier = Modifier
.padding(vertical = 16.dp)
.size(240.dp)
.clip(RoundedCornerShape(16.dp)),
)
// --- Track metadata --------------------------------------------------
Text(
text = song?.title ?: song?.uri ?: "Nothing playing",
@@ -171,7 +192,7 @@ fun NowPlayingScreen(
)
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(24.dp))
TextButton(onClick = vm::disconnect) {
Text("Disconnect")
@@ -67,7 +67,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun setVolume(volume: Int) = manager.setVolume(volume)
fun setRepeat(on: Boolean) = manager.setRepeat(on)
fun setRandom(on: Boolean) = manager.setRandom(on)
fun setConsume(on: Boolean) = manager.setConsume(on)
fun playAlbum(album: String, albumArtist: String?) = manager.playAlbum(album, albumArtist)
suspend fun loadAlbums() = manager.loadAlbums()
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
suspend fun loadQueue() = manager.loadQueue()
}
@@ -0,0 +1,154 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.VolumeUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
/**
* "Up next": the tracks that will auto-play from the current one to the end of
* the queue. We deliberately drop the already-played prefix (MPD keeps played
* tracks in the queue unless consume mode is on) so the list reflects what will
* actually play before MPD stops — and it shrinks as playback advances even
* without consume. Repeat/single change that, so we caption those cases.
*
* The full queue is re-fetched when its version changes; the upcoming slice is
* derived from the live current-song position, so it updates as playback moves.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
val status by vm.status.collectAsStateWithLifecycle()
// Reload the full queue when the queue version bumps (edits/consume).
val fullQueue by produceState<List<MpdSong>?>(initialValue = null, status?.playlistVersion) {
value = vm.loadQueue()
}
// Slice from the current song to the end. `song` is the current queue index.
val currentPos = status?.song
val upcoming: List<MpdSong>? = fullQueue?.let { q ->
if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q
}
val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true)
Scaffold(
topBar = {
TopAppBar(
title = { Text(upcoming?.let { "Up Next (${it.size})" } ?: "Up Next") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { innerPadding ->
when {
upcoming == null -> Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
upcoming.isEmpty() -> Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) { Text("Nothing queued", color = MaterialTheme.colorScheme.onSurfaceVariant) }
else -> LazyColumn(modifier = Modifier.padding(innerPadding)) {
playbackModeCaption(status)?.let { caption ->
item {
Text(
caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
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)) } }
},
)
}
}
}
}
}
/** Explains what will actually happen at the end of the list, given the modes. */
private fun playbackModeCaption(status: MpdStatus?): String? = when {
status == null -> null
status.repeat && status.single -> "Repeating the current track."
status.single -> "Single mode — stops after the current track."
status.repeat -> "Repeat on — the queue loops, so playback won't stop."
else -> null
}
private fun formatDuration(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0)
return "%d:%02d".format(total / 60, total % 60)
}
@@ -1,5 +1,6 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +19,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -38,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var confirmReset by remember { mutableStateOf(false) }
Scaffold(
@@ -73,6 +76,27 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
trailingContent = { Text(settings.port.toString()) },
)
Text(
"Playback",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp),
)
// Consume is a live MPD playback option; the switch reflects the
// server's current state and toggling sends the `consume` command.
val consumeOn = status?.consume == true
ListItem(
modifier = Modifier.clickable { vm.setConsume(!consumeOn) },
headlineContent = { Text("Consume mode") },
supportingContent = { Text("Remove each track from the queue after it plays") },
trailingContent = {
Switch(
checked = consumeOn,
onCheckedChange = { vm.setConsume(it) },
)
},
)
Spacer(Modifier.height(24.dp))
OutlinedButton(