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
View File
@@ -108,6 +108,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media) implementation(libs.androidx.media)
implementation(libs.coil.compose)
// The Compose BOM aligns every Compose artifact to one tested version set, // The Compose BOM aligns every Compose artifact to one tested version set,
// so the individual Compose deps below are declared without versions. // so the individual Compose deps below are declared without versions.
@@ -1,13 +1,21 @@
package ca.ksamad.musicremote package ca.ksamad.musicremote
import android.app.Application import android.app.Application
import ca.ksamad.musicremote.playback.ArtImageLoader
import ca.ksamad.musicremote.playback.MpdConnectionManager 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 * 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) } 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. // as soon as the process starts, not only when the UI first observes it.
manager 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.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException import java.io.IOException
@@ -112,6 +114,9 @@ class MpdClient(
command.execute(MpdCommands.password(pw)) command.execute(MpdCommands.password(pw))
idle.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 commandConn = command
idleConn = idle idleConn = idle
} }
@@ -126,6 +131,7 @@ class MpdClient(
idleConn?.close() idleConn?.close()
commandConn?.close() commandConn?.close()
} }
closeArtConnections()
idleConn = null idleConn = null
commandConn = null commandConn = null
if (_connectionState.value !is MpdConnectionState.Error) { if (_connectionState.value !is MpdConnectionState.Error) {
@@ -189,6 +195,43 @@ class MpdClient(
conn.execute(MpdCommands.play()) 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 ---------------------------------------------------------- // --- Internals ----------------------------------------------------------
private suspend fun run(commandLine: String) { 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). */ /** Re-read `status` and `currentsong` into the flows (single round-trip). */
private suspend fun refresh() { private suspend fun refresh() {
val (status, song) = withCommand { conn -> val (status, song) = withCommand { conn ->
@@ -311,6 +415,7 @@ class MpdClient(
} }
idleConn = null idleConn = null
commandConn = null commandConn = null
closeArtConnections()
} }
private fun failAndClose(message: String) { private fun failAndClose(message: String) {
@@ -320,6 +425,8 @@ class MpdClient(
companion object { companion object {
private const val COMMAND_READ_TIMEOUT_MS = 10_000 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 KEEPALIVE_INTERVAL_MS = 25_000L
private const val RECONNECT_BACKOFF_MS = 1_000L private const val RECONNECT_BACKOFF_MS = 1_000L
private const val MAX_RECONNECT_ATTEMPTS = 5 private const val MAX_RECONNECT_ATTEMPTS = 5
@@ -79,4 +79,23 @@ object MpdCommands {
} else { } else {
MpdProtocol.command("findadd", "album", album, "albumartist", albumArtist) 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 { companion object {
// ACK [2@1] {play} Bad song index // 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 * 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.MpdClient
import ca.ksamad.musicremote.mpd.MpdConnectionState import ca.ksamad.musicremote.mpd.MpdConnectionState
import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.musicremote.mpd.model.MpdAlbum
import ca.ksamad.musicremote.mpd.model.MpdSong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -130,12 +131,27 @@ class MpdConnectionManager(context: Context) {
} }
fun setRepeat(on: Boolean) = fire { setRepeat(on) } fun setRepeat(on: Boolean) = fire { setRepeat(on) }
fun setRandom(on: Boolean) = fire { setRandom(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) } 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. */ /** 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())
/** 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. * 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, * 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.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
@@ -17,6 +18,10 @@ import androidx.core.content.ContextCompat
import androidx.media.VolumeProviderCompat import androidx.media.VolumeProviderCompat
import androidx.media.app.NotificationCompat.MediaStyle import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media.session.MediaButtonReceiver 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.MainActivity
import ca.ksamad.musicremote.MusicRemoteApplication import ca.ksamad.musicremote.MusicRemoteApplication
import ca.ksamad.musicremote.R import ca.ksamad.musicremote.R
@@ -28,7 +33,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -103,6 +111,10 @@ class PlaybackService : Service() {
override fun onSeekTo(pos: Long) = manager.seekTo(pos / 1000.0) 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() { private fun observeState() {
scope.launch { scope.launch {
combine(manager.status, manager.currentSong) { s, song -> s to song } combine(manager.status, manager.currentSong) { s, song -> s to song }
@@ -113,6 +125,21 @@ class PlaybackService : Service() {
postNotification() 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 { scope.launch {
manager.connectionState.collect { st -> manager.connectionState.collect { st ->
if (st is MpdConnectionState.Disconnected || st is MpdConnectionState.Error) { 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 = private fun buildMetadata(song: MpdSong?, status: MpdStatus?): MediaMetadataCompat =
MediaMetadataCompat.Builder().apply { MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, song?.title ?: song?.uri ?: "") putString(MediaMetadataCompat.METADATA_KEY_TITLE, song?.title ?: song?.uri ?: "")
@@ -130,6 +164,9 @@ class PlaybackService : Service() {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song?.album ?: "") putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song?.album ?: "")
val durationMs = ((song?.duration ?: status?.duration ?: 0.0) * 1000).toLong() val durationMs = ((song?.duration ?: status?.duration ?: 0.0) * 1000).toLong()
putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs)
if (artBitmap != null && artUri == song?.uri) {
putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, artBitmap)
}
}.build() }.build()
private fun buildPlaybackState(status: MpdStatus?): PlaybackStateCompat { 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.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
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.Album
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -23,9 +24,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.musicremote.mpd.model.MpdAlbum
import ca.ksamad.musicremote.playback.AlbumArt
/** /**
* Browse every album on the server. Tapping one replaces the queue with that * 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() onPlay()
}, },
leadingContent = { 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 = { headlineContent = {
Text(album.name, maxLines = 1, overflow = TextOverflow.Ellipsis) 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. * (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). */ /** 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 @Composable
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
@@ -55,10 +55,12 @@ fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
onBack = { overlay = PlayerOverlay.None }, onBack = { overlay = PlayerOverlay.None },
onPlay = { overlay = PlayerOverlay.None }, onPlay = { overlay = PlayerOverlay.None },
) )
PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
PlayerOverlay.None -> NowPlayingScreen( PlayerOverlay.None -> NowPlayingScreen(
vm, vm,
onOpenSettings = { overlay = PlayerOverlay.Settings }, onOpenSettings = { overlay = PlayerOverlay.Settings },
onOpenLibrary = { overlay = PlayerOverlay.Albums }, onOpenLibrary = { overlay = PlayerOverlay.Albums },
onOpenQueue = { overlay = PlayerOverlay.Queue },
) )
} }
} }
@@ -1,5 +1,7 @@
package ca.ksamad.musicremote.ui 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
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.QueueMusic
import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
@@ -37,11 +41,13 @@ import androidx.compose.runtime.remember
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
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign 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.musicremote.mpd.model.MpdStatus
import ca.ksamad.musicremote.playback.SongArt
import ca.ksamad.musicremote.mpd.model.PlayerState import ca.ksamad.musicremote.mpd.model.PlayerState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -56,6 +62,7 @@ fun NowPlayingScreen(
vm: PlayerViewModel, vm: PlayerViewModel,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenLibrary: () -> Unit, onOpenLibrary: () -> Unit,
onOpenQueue: () -> Unit,
) { ) {
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
val song by vm.currentSong.collectAsStateWithLifecycle() val song by vm.currentSong.collectAsStateWithLifecycle()
@@ -64,11 +71,15 @@ fun NowPlayingScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// --- Top bar: library + settings ------------------------------------ // --- Top bar: library + settings ------------------------------------
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onOpenQueue) {
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
}
IconButton(onClick = onOpenLibrary) { IconButton(onClick = onOpenLibrary) {
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums") 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 -------------------------------------------------- // --- Track metadata --------------------------------------------------
Text( Text(
text = song?.title ?: song?.uri ?: "Nothing playing", 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) { TextButton(onClick = vm::disconnect) {
Text("Disconnect") Text("Disconnect")
@@ -67,7 +67,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun setVolume(volume: Int) = manager.setVolume(volume) fun setVolume(volume: Int) = manager.setVolume(volume)
fun setRepeat(on: Boolean) = manager.setRepeat(on) fun setRepeat(on: Boolean) = manager.setRepeat(on)
fun setRandom(on: Boolean) = manager.setRandom(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) fun playAlbum(album: String, albumArtist: String?) = manager.playAlbum(album, albumArtist)
suspend fun loadAlbums() = manager.loadAlbums() 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 package ca.ksamad.musicremote.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +19,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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
@@ -38,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable @Composable
fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) { fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var confirmReset by remember { mutableStateOf(false) } var confirmReset by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@@ -73,6 +76,27 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
trailingContent = { Text(settings.port.toString()) }, 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)) Spacer(Modifier.height(24.dp))
OutlinedButton( OutlinedButton(
+109 -9
View File
@@ -32,10 +32,14 @@
"pom": "sha256-/leyKEF/TXxneQPcYftKfPmT1gNJneJtjYET5HfMTxs=" "pom": "sha256-/leyKEF/TXxneQPcYftKfPmT1gNJneJtjYET5HfMTxs="
}, },
"androidx/annotation#annotation-jvm/1.8.1": { "androidx/annotation#annotation-jvm/1.8.1": {
"jar": "sha256-mqsybZSSgAmRhUNgrCSPSTzn98MYNRkwm3is6eJA9vY=",
"module": "sha256-yVnjsM3HXBXv4BYF+laqefAz45I44VBji4+r3mqhIaA=", "module": "sha256-yVnjsM3HXBXv4BYF+laqefAz45I44VBji4+r3mqhIaA=",
"pom": "sha256-1JIDczqm+uBGw6PeTnlu7TR1lXVUhqZCc5iYRHWXULQ=" "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": { "androidx/annotation#annotation/1.1.0": {
"pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4=" "pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4="
}, },
@@ -55,9 +59,20 @@
"module": "sha256-5jhuha/dhlBE4hZXXkk+05pjpjJb2SU3miFCnDlByLU=", "module": "sha256-5jhuha/dhlBE4hZXXkk+05pjpjJb2SU3miFCnDlByLU=",
"pom": "sha256-txIll07Ah+uWwl72gZ9VscIvUw6FykRrpzX7Zu0E/1w=" "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": { "androidx/annotation/annotation-experimental/1.4.1/annotation-experimental-1.4.1": {
"aar": "sha256-a9THx0dvgmDNO9u4EYNYPpP8n3kMJ96n3DFBgcv4eqA=" "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": { "androidx/arch/core#core-common/2.2.0": {
"jar": "sha256-ZTCKBrHADuGGy54ZMhOD8EO5k4E/FSLEf0o+MwO9ukE=", "jar": "sha256-ZTCKBrHADuGGy54ZMhOD8EO5k4E/FSLEf0o+MwO9ukE=",
"module": "sha256-7fQgDP3C2UYjIlLJnl3LnGG7kJ61RQsmE9HU/cl0uYE=", "module": "sha256-7fQgDP3C2UYjIlLJnl3LnGG7kJ61RQsmE9HU/cl0uYE=",
@@ -431,6 +446,13 @@
"androidx/emoji2/emoji2/1.3.0/emoji2-1.3.0": { "androidx/emoji2/emoji2/1.3.0/emoji2-1.3.0": {
"aar": "sha256-K/I4GLI6mW3aobX9W7MhKdr/a7stzhUWbi/M3SAQsaU=" "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": { "androidx/graphics#graphics-path/1.0.1": {
"module": "sha256-P2/H6W+KH9IQRdp/LjMq71KKofVrZFX7jyUEOq+g4bg=", "module": "sha256-P2/H6W+KH9IQRdp/LjMq71KKofVrZFX7jyUEOq+g4bg=",
"pom": "sha256-hm+zV8cUb192eK1E5LxsKCPVaN784e0oBbtsiZYGsig=" "pom": "sha256-hm+zV8cUb192eK1E5LxsKCPVaN784e0oBbtsiZYGsig="
@@ -560,8 +582,12 @@
"module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=", "module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=",
"pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g=" "pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g="
}, },
"androidx/profileinstaller/profileinstaller/1.3.1/profileinstaller-1.3.1": { "androidx/profileinstaller#profileinstaller/1.4.1": {
"aar": "sha256-0OQC7DHyQCih3H62oKP52WNcFFk5LNc0OWNDtz1nOUg=" "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": { "androidx/savedstate#savedstate-ktx/1.2.1": {
"module": "sha256-lDWRhLK6UcD0mKK5BV03s3IjHvm8xUpJcqyZ8DA6//E=", "module": "sha256-lDWRhLK6UcD0mKK5BV03s3IjHvm8xUpJcqyZ8DA6//E=",
@@ -577,6 +603,10 @@
"androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1": { "androidx/savedstate/savedstate/1.2.1/savedstate-1.2.1": {
"aar": "sha256-IafUvPa9uUrXuSg4AVKTALT7uICMpPGR4M3Ob9jkcFo=" "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": { "androidx/startup#startup-runtime/1.1.1": {
"module": "sha256-z9ls9kUMbitpdZiSRymtmgSVxaT89Ovufi+BsH5BWGU=", "module": "sha256-z9ls9kUMbitpdZiSRymtmgSVxaT89Ovufi+BsH5BWGU=",
"pom": "sha256-9BFLXGhZuxvDyvKBy21vJZmPp/cpLGTOrqdKkyEOdGs=" "pom": "sha256-9BFLXGhZuxvDyvKBy21vJZmPp/cpLGTOrqdKkyEOdGs="
@@ -591,6 +621,18 @@
"androidx/tracing/tracing/1.0.0/tracing-1.0.0": { "androidx/tracing/tracing/1.0.0/tracing-1.0.0": {
"aar": "sha256-B7i2E5ZluIShYuzPl4kcpQ9/VoMSM78lForgT3tWhhI=" "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": { "androidx/versionedparcelable#versionedparcelable/1.1.1": {
"pom": "sha256-X1HmWHPKYS3jg4+pDS7pW40EDv0xucOQoZv5TWFc2y8=" "pom": "sha256-X1HmWHPKYS3jg4+pDS7pW40EDv0xucOQoZv5TWFc2y8="
}, },
@@ -785,6 +827,13 @@
} }
}, },
"https://repo.maven.apache.org/maven2": { "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": { "com/google/android#annotations/4.1.1.4": {
"jar": "sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=", "jar": "sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=",
"pom": "sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=" "pom": "sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng="
@@ -908,14 +957,22 @@
"pom": "sha256-4avX8RFs9eDFmUdpPiGJII7JQpayozlMlZ41EdOZp7A=" "pom": "sha256-4avX8RFs9eDFmUdpPiGJII7JQpayozlMlZ41EdOZp7A="
}, },
"com/squareup/okio#okio-jvm/3.4.0": { "com/squareup/okio#okio-jvm/3.4.0": {
"jar": "sha256-ATnselBtu9VMrWIpGwGcuFBTS+CXyMZsEADV++jt7z4=",
"module": "sha256-b+wTzzNhYANkv8xoAEr3x2DOrx/Bu8yVh0KuTWHbFRI=", "module": "sha256-b+wTzzNhYANkv8xoAEr3x2DOrx/Bu8yVh0KuTWHbFRI=",
"pom": "sha256-ZqiFeYHqbD7IuGE6QJvxLxR5Ze+UmMt6jzcXKMTi2k0=" "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": { "com/squareup/okio#okio/3.4.0": {
"module": "sha256-aRc2CEF6IRPm+mr+t7RUCyDfcM/aP6Fsc6qk+nAv+tM=", "module": "sha256-aRc2CEF6IRPm+mr+t7RUCyDfcM/aP6Fsc6qk+nAv+tM=",
"pom": "sha256-QOr9s+epasxcFR3pzL7BKFDy37XL53Ph90zrU2JVggM=" "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": { "com/sun/activation#all/1.2.0": {
"pom": "sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=" "pom": "sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ="
}, },
@@ -967,6 +1024,50 @@
"jar": "sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=", "jar": "sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=",
"pom": "sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=" "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": { "io/grpc#grpc-api/1.57.0": {
"jar": "sha256-jSw4Qpn4Tuiqf2cPAOfLJrh+IxzzCRR0MHsyt2kQ9xw=", "jar": "sha256-jSw4Qpn4Tuiqf2cPAOfLJrh+IxzzCRR0MHsyt2kQ9xw=",
"pom": "sha256-w/BUp8iGFkfQpVglsKlJ9E/PycZPR5CD2WgTgUxQJhI=" "pom": "sha256-w/BUp8iGFkfQpVglsKlJ9E/PycZPR5CD2WgTgUxQJhI="
@@ -1226,6 +1327,10 @@
"jar": "sha256-ew8ZckCCy/y8ZuWr6iubySzwih6hHhkZM+1DgB6zzQU=", "jar": "sha256-ew8ZckCCy/y8ZuWr6iubySzwih6hHhkZM+1DgB6zzQU=",
"pom": "sha256-yUkPZVEyMo3yz7z990P1P8ORbWwdEENxdabKbjpndxw=" "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": { "org/jetbrains/intellij/deps#trove4j/1.0.20200330": {
"jar": "sha256-xf1yW/+rUYRr88d9sTg8YKquv+G3/i8A0j/ht98KQ50=", "jar": "sha256-xf1yW/+rUYRr88d9sTg8YKquv+G3/i8A0j/ht98KQ50=",
"pom": "sha256-h3IcuqZaPJfYsbqdIHhA8WTJ/jh1n8nqEP/iZWX40+k=" "pom": "sha256-h3IcuqZaPJfYsbqdIHhA8WTJ/jh1n8nqEP/iZWX40+k="
@@ -1368,16 +1473,11 @@
"module": "sha256-b134r2M2AKa5z7D8x2SvPVEZ83Zndne5G2rugWsdMKs=", "module": "sha256-b134r2M2AKa5z7D8x2SvPVEZ83Zndne5G2rugWsdMKs=",
"pom": "sha256-X0As+413MZW5ZwUBJMnom1+EsXJGThiUkpeJv1xMLyk=" "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": { "org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.9.20": {
"jar": "sha256-xUUdZ6J/M6/QmRPGfhzro4l65wiEsk7w/3EVflW2CGU=", "jar": "sha256-xUUdZ6J/M6/QmRPGfhzro4l65wiEsk7w/3EVflW2CGU=",
"pom": "sha256-AS4cVe1q3kF7y4JBEuvqaCrWJd++4WCFw3nM+hT68DM=" "pom": "sha256-AS4cVe1q3kF7y4JBEuvqaCrWJd++4WCFw3nM+hT68DM="
}, },
"org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.0": { "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.0": {
"jar": "sha256-BbYoBEQbDJoZILa31c9zKaTiS2JYR44ysfBGygGQCUY=",
"pom": "sha256-K7bHVRuXx7oCn5hmWC56oZ1jq/1M1T2j/AxGLzq1/CY=" "pom": "sha256-K7bHVRuXx7oCn5hmWC56oZ1jq/1M1T2j/AxGLzq1/CY="
}, },
"org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.9.20": { "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.9.20": {
+66 -10
View File
@@ -6,6 +6,25 @@ priority; not a commitment.
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done 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 ## [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 - Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, 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.
- Room to grow: password field, connection timeout, theme, keep-screen-on — and - **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`,
if screens multiply, revisit adopting Navigation-Compose. 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 ## [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" gesture). Could later add a long-press / menu for "add to queue" and an
album-detail/track view. Artist browse is a future extension. 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 <uri> <offset>` and - `MpdClient.songArt(uri)` / `albumArt(album, artist)` loop the chunked art
`readpicture <uri> <offset>` (embedded art). **`MpdConnection` already handles protocol: try `albumart` (folder cover), fall back to `readpicture` (embedded);
binary responses**, so the transport groundwork is done — add the commands, `albumArt` resolves a track via `find … window 0:1` first. `binarylimit` is
loop over offsets to fetch the whole image, and decode. raised on connect so covers transfer in ~1 round-trip.
- Needs an image loader + caching. Coil (`io.coil-kt`) is the standard Compose - **Coil** (`io.coil-kt.coil3`) with a custom `Fetcher`/`Keyer` over the
choice; a custom `MpdArtFetcher` could feed it. Dependency → `deps.json` regen. `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 ## [x] 6. BUG: idle connection drops after a few minutes → kicked to connect page
+3
View File
@@ -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) coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client)
datastore = "1.1.1" # Jetpack DataStore (persisting connection settings) datastore = "1.1.1" # Jetpack DataStore (persisting connection settings)
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume 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" junit = "4.13.2"
[libraries] [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" } 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-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins] [plugins]