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