From 98a792e202aec91de693d9befcf29d69500831ff Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Mon, 27 Jul 2026 00:11:25 -0400 Subject: [PATCH] feat: art images and queue --- app/build.gradle.kts | 1 + .../musicremote/MusicRemoteApplication.kt | 15 +- .../ca/ksamad/musicremote/mpd/MpdClient.kt | 107 ++++++++++++ .../ca/ksamad/musicremote/mpd/MpdCommands.kt | 19 +++ .../ca/ksamad/musicremote/mpd/MpdException.kt | 5 +- .../musicremote/playback/ArtImageLoader.kt | 117 +++++++++++++ .../ca/ksamad/musicremote/playback/MpdArt.kt | 14 ++ .../playback/MpdConnectionManager.kt | 16 ++ .../musicremote/playback/PlaybackService.kt | 37 +++++ .../ca/ksamad/musicremote/ui/AlbumsScreen.kt | 13 +- .../ca/ksamad/musicremote/ui/ArtImage.kt | 45 +++++ .../ksamad/musicremote/ui/MusicRemoteApp.kt | 4 +- .../ksamad/musicremote/ui/NowPlayingScreen.kt | 23 ++- .../ksamad/musicremote/ui/PlayerViewModel.kt | 4 + .../ca/ksamad/musicremote/ui/QueueScreen.kt | 154 ++++++++++++++++++ .../ksamad/musicremote/ui/SettingsScreen.kt | 24 +++ deps.json | 118 +++++++++++++- docs/TODO.md | 76 +++++++-- gradle/libs.versions.toml | 3 + 19 files changed, 769 insertions(+), 26 deletions(-) create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fad96b0..c5aecf1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.media) + implementation(libs.coil.compose) // The Compose BOM aligns every Compose artifact to one tested version set, // so the individual Compose deps below are declared without versions. diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt b/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt index e6dc6bf..ce89333 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/MusicRemoteApplication.kt @@ -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) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt index 880fc92..237fbff 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt @@ -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() + + private suspend fun 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 diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt index 3703fc0..15ac01b 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt @@ -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()) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt index 8eb3f17..ef712e5 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt @@ -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 diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt new file mode 100644 index 0000000..6ac8624 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/ArtImageLoader.kt @@ -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 { + 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 { + override fun create(data: MpdArtData, options: Options, imageLoader: ImageLoader): Fetcher = + MpdArtFetcher(data, imageLoader.diskCache, fetch) + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt new file mode 100644 index 0000000..aadfcec --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdArt.kt @@ -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 diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt index 9a6bc87..2550ea4 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt @@ -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 = runCatching { client.albums() }.getOrDefault(emptyList()) + /** One-shot play-queue fetch; returns empty on any failure. */ + suspend fun loadQueue(): List = 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, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt index 3999b81..5fb6727 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/PlaybackService.kt @@ -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 { diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt index dcfe164..df9e27d 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt @@ -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) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt new file mode 100644 index 0000000..e7dd749 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/ArtImage.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt index eb84960..75d5193 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt @@ -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 }, ) } } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt index dabea6f..971dd75 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt @@ -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") diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt index 6a49805..f897c2b 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt @@ -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() } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt new file mode 100644 index 0000000..4331b48 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt @@ -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?>(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? = 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) +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt index 2d1c3dd..0243add 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt @@ -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( diff --git a/deps.json b/deps.json index 4c83503..1e16eab 100644 --- a/deps.json +++ b/deps.json @@ -32,10 +32,14 @@ "pom": "sha256-/leyKEF/TXxneQPcYftKfPmT1gNJneJtjYET5HfMTxs=" }, "androidx/annotation#annotation-jvm/1.8.1": { - "jar": "sha256-mqsybZSSgAmRhUNgrCSPSTzn98MYNRkwm3is6eJA9vY=", "module": "sha256-yVnjsM3HXBXv4BYF+laqefAz45I44VBji4+r3mqhIaA=", "pom": "sha256-1JIDczqm+uBGw6PeTnlu7TR1lXVUhqZCc5iYRHWXULQ=" }, + "androidx/annotation#annotation-jvm/1.9.1": { + "jar": "sha256-HjQ5F+vye6lv5NxSscrX/TK3OPvGNVu2zVs7MF1yEtA=", + "module": "sha256-A/tlkXfIYY5HQlklwRvJHzhHA+omwmW+myXNeSkrURw=", + "pom": "sha256-ibmcIY1gAZMWtQqreYFnB7whaWyJagMOGxrgOJYby44=" + }, "androidx/annotation#annotation/1.1.0": { "pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4=" }, @@ -55,9 +59,20 @@ "module": "sha256-5jhuha/dhlBE4hZXXkk+05pjpjJb2SU3miFCnDlByLU=", "pom": "sha256-txIll07Ah+uWwl72gZ9VscIvUw6FykRrpzX7Zu0E/1w=" }, + "androidx/annotation#annotation/1.9.1": { + "module": "sha256-8gSwW3KKl1YXGLxxYkLkfGKcAIWoDudPylPU1ji8vj8=", + "pom": "sha256-xzOIHC4X1ffIZhzAKpFZyxYLeyCUon1ZORbIfT4lBjY=" + }, "androidx/annotation/annotation-experimental/1.4.1/annotation-experimental-1.4.1": { "aar": "sha256-a9THx0dvgmDNO9u4EYNYPpP8n3kMJ96n3DFBgcv4eqA=" }, + "androidx/appcompat#appcompat-resources/1.7.0": { + "module": "sha256-18ygtVPsEJ7yCscK5kOPWE/Gu16yaaf1tAmOAsbWh/k=", + "pom": "sha256-u/Kluax4kEydp5LhZzF5MnT5X7SmEjBxkUCz4O9za6o=" + }, + "androidx/appcompat/appcompat-resources/1.7.0/appcompat-resources-1.7.0": { + "aar": "sha256-VbZ3hgJoDzwojONQosLT3RWNl9v/xjR2J1gmZVWCw4g=" + }, "androidx/arch/core#core-common/2.2.0": { "jar": "sha256-ZTCKBrHADuGGy54ZMhOD8EO5k4E/FSLEf0o+MwO9ukE=", "module": "sha256-7fQgDP3C2UYjIlLJnl3LnGG7kJ61RQsmE9HU/cl0uYE=", @@ -431,6 +446,13 @@ "androidx/emoji2/emoji2/1.3.0/emoji2-1.3.0": { "aar": "sha256-K/I4GLI6mW3aobX9W7MhKdr/a7stzhUWbi/M3SAQsaU=" }, + "androidx/exifinterface#exifinterface/1.3.7": { + "module": "sha256-9wgZUZ7MMgAoNFx8DDGNgVcFXoP8RmyJnNNE3U9jCjE=", + "pom": "sha256-N9vGuHokCc6lWI1YyPOsq56oLMaI7leiAaIQRQ/zDg4=" + }, + "androidx/exifinterface/exifinterface/1.3.7/exifinterface-1.3.7": { + "aar": "sha256-Do8YMiZsWwZnrT07EJjmJOSaCQdUk6AUp+iK8B/TCtM=" + }, "androidx/graphics#graphics-path/1.0.1": { "module": "sha256-P2/H6W+KH9IQRdp/LjMq71KKofVrZFX7jyUEOq+g4bg=", "pom": "sha256-hm+zV8cUb192eK1E5LxsKCPVaN784e0oBbtsiZYGsig=" @@ -560,8 +582,12 @@ "module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=", "pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g=" }, - "androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1": { - "aar": "sha256-0OQC7DHyQCih3H62oKP52WNcFFk5LNc0OWNDtz1nOUg=" + "androidx/profileinstaller#profileinstaller/1.4.1": { + "module": "sha256-bxHPZeS/hESZXMc+WqP4GwgtZucgwFkrxfyA0/c4UPc=", + "pom": "sha256-bd5DgntAU15AU9HFLUsiekEVVsKJ5lQAyaHMYeK0HOM=" + }, + "androidx/profileinstaller/profileinstaller/1.4.1/profileinstaller-1.4.1": { + "aar": "sha256-tRn5MX3tHiwcKZMDjAaS4w2jJsqZCX2TMf8tOlhhpCg=" }, "androidx/savedstate#savedstate-ktx/1.2.1": { "module": "sha256-lDWRhLK6UcD0mKK5BV03s3IjHvm8xUpJcqyZ8DA6//E=", @@ -577,6 +603,10 @@ "androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1": { "aar": "sha256-IafUvPa9uUrXuSg4AVKTALT7uICMpPGR4M3Ob9jkcFo=" }, + "androidx/startup#startup-runtime/1.0.0": { + "module": "sha256-QO/8oNbuH94yvClol+VOu8xM9KopsMUxA2y9KoJKPCQ=", + "pom": "sha256-GQtlQlHxEEUvZ/ahPXZuj+4IEfB+bwsPd9WJBiJqy6g=" + }, "androidx/startup#startup-runtime/1.1.1": { "module": "sha256-z9ls9kUMbitpdZiSRymtmgSVxaT89Ovufi+BsH5BWGU=", "pom": "sha256-9BFLXGhZuxvDyvKBy21vJZmPp/cpLGTOrqdKkyEOdGs=" @@ -591,6 +621,18 @@ "androidx/tracing/tracing/1.0.0/tracing-1.0.0": { "aar": "sha256-B7i2E5ZluIShYuzPl4kcpQ9/VoMSM78lForgT3tWhhI=" }, + "androidx/vectordrawable#vectordrawable-animated/1.1.0": { + "pom": "sha256-J2ogEWtwX7dbkAPulJbFb2/TsyN1+yMkcoEeumCgQL0=" + }, + "androidx/vectordrawable#vectordrawable/1.1.0": { + "pom": "sha256-Ww4tWyF55UgEeFy8Ic5fRzteHd1VpX2kgulNzTlJK7I=" + }, + "androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0": { + "aar": "sha256-dtosUCNx2cOAVN9eKySNANqHgJ7QWPM2Pq6Hzl4kA/g=" + }, + "androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0": { + "aar": "sha256-Rv1jOsAbSbf8q8JjvwmMWouemml3TSNO3MoE+wLfjiY=" + }, "androidx/versionedparcelable#versionedparcelable/1.1.1": { "pom": "sha256-X1HmWHPKYS3jg4+pDS7pW40EDv0xucOQoZv5TWFc2y8=" }, @@ -785,6 +827,13 @@ } }, "https://repo.maven.apache.org/maven2": { + "com/google/accompanist#accompanist-drawablepainter/0.36.0": { + "module": "sha256-LR2pP7TwcbJ3xHuLD439Ie53v7BUsWpWeT/xjThB2A8=", + "pom": "sha256-vtt8+Ze06haVCbb/lyd91RMqEX++it8iYbSB+Wkvxfk=" + }, + "com/google/accompanist/accompanist-drawablepainter/0.36.0/accompanist-drawablepainter-0.36.0": { + "aar": "sha256-WZE3xT+SHJAe5A3wVqf5QKas2w3Y8Fdj3uyM9JWkCEg=" + }, "com/google/android#annotations/4.1.1.4": { "jar": "sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=", "pom": "sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=" @@ -908,14 +957,22 @@ "pom": "sha256-4avX8RFs9eDFmUdpPiGJII7JQpayozlMlZ41EdOZp7A=" }, "com/squareup/okio#okio-jvm/3.4.0": { - "jar": "sha256-ATnselBtu9VMrWIpGwGcuFBTS+CXyMZsEADV++jt7z4=", "module": "sha256-b+wTzzNhYANkv8xoAEr3x2DOrx/Bu8yVh0KuTWHbFRI=", "pom": "sha256-ZqiFeYHqbD7IuGE6QJvxLxR5Ze+UmMt6jzcXKMTi2k0=" }, + "com/squareup/okio#okio-jvm/3.9.1": { + "jar": "sha256-/m/pE3j5v6ewjDhkgozkGABc8orMoS6IR+tlxWXDdQA=", + "module": "sha256-sK+pGSxC18Rj3jjWMlk2xpAdnjtxSXNf/RihHfMQNKs=", + "pom": "sha256-VXZInO8kBTNoAPxt6VhUU18zVxpO/lpa0OybmwkNIdU=" + }, "com/squareup/okio#okio/3.4.0": { "module": "sha256-aRc2CEF6IRPm+mr+t7RUCyDfcM/aP6Fsc6qk+nAv+tM=", "pom": "sha256-QOr9s+epasxcFR3pzL7BKFDy37XL53Ph90zrU2JVggM=" }, + "com/squareup/okio#okio/3.9.1": { + "module": "sha256-m5C0J0pa1gLdV01tS0iQNmOy3ppgufw0AiSCk9hD4SE=", + "pom": "sha256-06DDd+epr8zbsCqrXgUNwhL/2pQNvUcOo5cSpDQt8I4=" + }, "com/sun/activation#all/1.2.0": { "pom": "sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=" }, @@ -967,6 +1024,50 @@ "jar": "sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=", "pom": "sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=" }, + "io/coil-kt/coil3#coil-android/3.0.4": { + "module": "sha256-Z8a3berxnMGJQG6EOEr/FG35NqAY5lyBGHysphBura0=", + "pom": "sha256-LAu0bdPPDG9VEca24c9j57QuCHjomSH7fi0avLRSiDg=" + }, + "io/coil-kt/coil3#coil-compose-android/3.0.4": { + "module": "sha256-HA5puxX0W2rh1H5aMtozTUBMkaZB0PqZ79C9AbkYuMg=", + "pom": "sha256-hvau2Krg6T2Fl6DpZt/OWJyxpJyAE3pHbidqRwSKD5I=" + }, + "io/coil-kt/coil3#coil-compose-core-android/3.0.4": { + "module": "sha256-c2wwModOlb2d6cO6wlpusKKPhLK4Qb15/ihvNuzz3MY=", + "pom": "sha256-qBRnS31tJZzHTBPlhWqAQ5/h4jfAdIkW4UftAIO1Xa0=" + }, + "io/coil-kt/coil3#coil-compose-core/3.0.4": { + "module": "sha256-FKBxXXATgcdRXAkFkJBFNAi77mJiT9NAo52iTDdy5FY=", + "pom": "sha256-9kXHfgKjdpw5KQnKmHQAEQao+9NIz9q1SECPEQPxHOI=" + }, + "io/coil-kt/coil3#coil-compose/3.0.4": { + "module": "sha256-DqeGW1h3oDatiVDjXXmOFl3AYZPkCxmsiwJ6OyGo1XA=", + "pom": "sha256-bV66NGxP2QGkVQ2rfmfDl2ai1toQ9vimvpalcEGJrcg=" + }, + "io/coil-kt/coil3#coil-core-android/3.0.4": { + "module": "sha256-vkid4TGYY2yZq1awBQ5l5lus1Alrsc0uLCrYOXOtK9s=", + "pom": "sha256-dMD5tVon9kG3Y6L1PBBhj+w4EWsSwyc0cSVgX8I8qWI=" + }, + "io/coil-kt/coil3#coil-core/3.0.4": { + "module": "sha256-YIwXJzJvQ0bY2GhJQwwBiW7336bHz2If25r9VA+y3yE=", + "pom": "sha256-x5zLCMMRqBF1FBcxNWJu/U6iElKfJENDnBO9rRjZFq8=" + }, + "io/coil-kt/coil3#coil/3.0.4": { + "module": "sha256-VX9hJWF4yz8/mC/2kvsSF0y5W8wFi3hCQr1ranAoDJM=", + "pom": "sha256-dKYeUQJAeV2nVqit0FWmOXHMAGFhvA1q8/yaSpmcDp8=" + }, + "io/coil-kt/coil3/coil-android/3.0.4/coil-android-3.0.4": { + "aar": "sha256-88XI8QtKyhA+FtU2uPEqc4/xdDQMcTe9+AJNS2xyWtA=" + }, + "io/coil-kt/coil3/coil-compose-android/3.0.4/coil-compose-android-3.0.4": { + "aar": "sha256-E8Wqu0KUD5n1tt9bozTATFY6x9ceHNA0GUzWiZT2VaE=" + }, + "io/coil-kt/coil3/coil-compose-core-android/3.0.4/coil-compose-core-android-3.0.4": { + "aar": "sha256-Am9gUEVzsuUkPxvMs5wazGl7neisOsJ5VF+Ur5OUO9o=" + }, + "io/coil-kt/coil3/coil-core-android/3.0.4/coil-core-android-3.0.4": { + "aar": "sha256-1qCUoy/aHiIXZuFCcVOlFdO7TQG20iHpRxIEoCJCxgA=" + }, "io/grpc#grpc-api/1.57.0": { "jar": "sha256-jSw4Qpn4Tuiqf2cPAOfLJrh+IxzzCRR0MHsyt2kQ9xw=", "pom": "sha256-w/BUp8iGFkfQpVglsKlJ9E/PycZPR5CD2WgTgUxQJhI=" @@ -1226,6 +1327,10 @@ "jar": "sha256-ew8ZckCCy/y8ZuWr6iubySzwih6hHhkZM+1DgB6zzQU=", "pom": "sha256-yUkPZVEyMo3yz7z990P1P8ORbWwdEENxdabKbjpndxw=" }, + "org/jetbrains/compose/foundation#foundation/1.6.11": { + "module": "sha256-r8hjgql1HTkvC4Sifw1/9YI2O8PU7mZiS8RlizXqbvo=", + "pom": "sha256-/ogwnRcMPCG9AF+/9UM9pMYgWRBOY64Lv1u3NC1ckrA=" + }, "org/jetbrains/intellij/deps#trove4j/1.0.20200330": { "jar": "sha256-xf1yW/+rUYRr88d9sTg8YKquv+G3/i8A0j/ht98KQ50=", "pom": "sha256-h3IcuqZaPJfYsbqdIHhA8WTJ/jh1n8nqEP/iZWX40+k=" @@ -1368,16 +1473,11 @@ "module": "sha256-b134r2M2AKa5z7D8x2SvPVEZ83Zndne5G2rugWsdMKs=", "pom": "sha256-X0As+413MZW5ZwUBJMnom1+EsXJGThiUkpeJv1xMLyk=" }, - "org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.8.0": { - "jar": "sha256-TIidHZgD9fLrbBWSprfmI2msdmDJ7uFauhb+wFkWNmY=", - "pom": "sha256-36lkSmrluJjuR1ux9X6DC6H3cK7mycFfgRKqOBGAGEo=" - }, "org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.9.20": { "jar": "sha256-xUUdZ6J/M6/QmRPGfhzro4l65wiEsk7w/3EVflW2CGU=", "pom": "sha256-AS4cVe1q3kF7y4JBEuvqaCrWJd++4WCFw3nM+hT68DM=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.0": { - "jar": "sha256-BbYoBEQbDJoZILa31c9zKaTiS2JYR44ysfBGygGQCUY=", "pom": "sha256-K7bHVRuXx7oCn5hmWC56oZ1jq/1M1T2j/AxGLzq1/CY=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.9.20": { diff --git a/docs/TODO.md b/docs/TODO.md index 39edcb8..8c2d9a4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -6,6 +6,25 @@ priority; not a commitment. Status legend: `[ ]` todo · `[~]` in progress · `[x]` done +## [x] Queue / "Up Next" view (post-roadmap) + +Done. `QueueScreen` (queue icon on the player) shows **"Up Next"** — the tracks +that will auto-play from the current one to the end of the queue, so the count +reflects "how many will play before MPD stops." + +- Reads the full queue via `playlistinfo` (`MpdClient.queue`), then slices from + `status.song` (current queue index) to the end. The already-played prefix is + dropped, and the slice re-derives from the live current position, so the list + shrinks as playback advances **even without consume mode** (MPD keeps played + tracks in the queue unless `consume` is on; only the current-song pointer + moves — that's why a plain queue view looked static). +- Current track is first, highlighted (bold + tinted + speaker icon). Tapping any + track jumps to it (`playid`). Full queue re-fetched on queue-version change. +- Captions the cases where the count isn't literally "tracks until stop": + repeat (loops), single (stops after current), repeat+single (repeats current). +- Rows show art thumbnails (disk-cached); art-less tracks show the disc + placeholder. Overlay nav via `PlayerOverlay.Queue`. + --- ## [x] 1. Proper icons @@ -33,8 +52,13 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects) - Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, only over the player screen; a `LaunchedEffect` drops it when leaving the player) — no Navigation-Compose dependency yet. -- Room to grow: password field, connection timeout, theme, keep-screen-on — and - if screens multiply, revisit adopting Navigation-Compose. +- **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`, + writes via the `consume` command). Consume is a dynamic MPD playback option, not + static config — toggling it persists server-side (MPD state file). Verified the + switch round-trips to the server (`consume: 0 → 1`). +- Room to grow: password field, connection timeout, theme, keep-screen-on, and + the other playback toggles (single/repeat/random) could join the Playback + section. If screens multiply, revisit adopting Navigation-Compose. ## [x] 3. Cast-style volume + OS media integration @@ -82,16 +106,48 @@ then returns to the now-playing screen. album" gesture). Could later add a long-press / menu for "add to queue" and an album-detail/track view. Artist browse is a future extension. -## [ ] 5. Album / artist images +## [x] 5. Album / artist images -Pull artwork for the now-playing track and for the album browse grid. +Done — art on the now-playing screen, the album grid, and the media +notification / lock-screen / QS card. -- MPD serves art over the protocol via `albumart ` and - `readpicture ` (embedded art). **`MpdConnection` already handles - binary responses**, so the transport groundwork is done — add the commands, - loop over offsets to fetch the whole image, and decode. -- Needs an image loader + caching. Coil (`io.coil-kt`) is the standard Compose - choice; a custom `MpdArtFetcher` could feed it. Dependency → `deps.json` regen. +- `MpdClient.songArt(uri)` / `albumArt(album, artist)` loop the chunked art + protocol: try `albumart` (folder cover), fall back to `readpicture` (embedded); + `albumArt` resolves a track via `find … window 0:1` first. `binarylimit` is + raised on connect so covers transfer in ~1 round-trip. +- **Coil** (`io.coil-kt.coil3`) with a custom `Fetcher`/`Keyer` over the + `MpdArtData` model (`SongArt`/`AlbumArt`), wired as the app's + `SingletonImageLoader.Factory`. **Disk cache** means each cover is fetched from + the server once ever — important for the Pi. `ArtImage` composable shows a disc + placeholder underneath. Service pulls the bitmap via Coil (shared cache) into + `METADATA_KEY_ALBUM_ART`. +- The now-playing screen is now vertically scrollable so art doesn't clip. +- **Art connection pool** (`MpdClient`, `ART_POOL_SIZE = 4`): covers are fetched + on a dedicated pool of connections, not the command connection — so they load + ~4× in parallel while scrolling *and* never block play/pause/volume. (Measured: + a batch of 8 covers went from ~1.8s serialized on the tail to ~4-way parallel.) + Each album cover is 3 round-trips (`find` → `albumart` ACK → `readpicture`), so + the Pi is the remaining limit; disk cache means it's a one-time cost per cover. +- **Disk-cache persistence:** Coil's disk cache is only auto-managed by its + *network* fetchers — a custom fetcher must read/write `imageLoader.diskCache` + itself, or art is only memory-cached (re-fetched every cold start). `MpdArtFetcher` + now serves from the disk snapshot when present and write-through-caches misses. + Verified: after force-stop + relaunch, 19/19 covers loaded as DISK hits, 0 + server fetches. (Known minor gap: art-*less* albums aren't negative-cached, so + they re-probe the server each cold start — future optimization if it matters.) + +Gotchas hit & fixed: +- **Portability bug (not art-specific):** `MpdAckException`'s ACK regex had bare + `]`/`}` — fine on the JVM (tests passed) but Android's ICU engine rejects them, + so the first ACK parsed on-device threw `ExceptionInInitializerError`. Escaped + them. This had masked all art (albumart's "no cover" ACK). +- Coil's reified `components { add(factory) }` didn't match the sealed-interface + subtypes; had to register with the explicit `add(factory, MpdArtData::class)`. +- Coil pinned to **3.0.4**: 3.5+ requires `compileSdk 36` (we're on 35). Bumping + later means updating `flake.nix` platform + AGP. + +Future: online art fallback (MusicBrainz/Last.fm) for albums with no local art, +like MALP; artist images. ## [x] 6. BUG: idle connection drops after a few minutes → kicked to connect page diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 585fb6e..47fd464 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ composeBom = "2024.10.01" # Compose Bill-of-Materials: pins all Compose l coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client) datastore = "1.1.1" # Jetpack DataStore (persisting connection settings) media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume +coil = "3.0.4" # Coil: image loading + memory/disk caching for album art + # (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36) junit = "4.13.2" [libraries] @@ -29,6 +31,7 @@ androidx-material-icons-extended = { group = "androidx.compose.material", name = kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins]