feat: art images and queue
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user