chore: format code

This commit is contained in:
2026-07-30 09:52:10 -04:00
parent d8977deaf3
commit 3e186b7c8b
34 changed files with 1368 additions and 997 deletions
@@ -8,15 +8,13 @@ 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.encore.playback.PlaybackService].
* Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any single Activity
* and can be shared with the foreground [ca.ksamad.encore.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.
* 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 EncoreApplication : Application(), SingletonImageLoader.Factory {
val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) }
override fun onCreate() {
@@ -20,7 +20,6 @@ import ca.ksamad.encore.playback.VolumeKeyDispatcher
import ca.ksamad.encore.ui.EncoreApp
class MainActivity : ComponentActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
@@ -38,9 +37,7 @@ class MainActivity : ComponentActivity() {
EncoreTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
modifier = Modifier.fillMaxSize().padding(innerPadding),
color = MaterialTheme.colorScheme.background,
) {
EncoreApp()
@@ -58,8 +55,9 @@ class MainActivity : ComponentActivity() {
// now-playing notification / QS controls won't show.
private fun maybeRequestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
val granted =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
+16 -10
View File
@@ -13,8 +13,8 @@ import androidx.compose.ui.platform.LocalContext
/**
* Material 3 theme for the app.
*
* On Android 12+ it uses "dynamic color" (the palette derived from the user's
* wallpaper); on older versions it falls back to a default light/dark scheme.
* On Android 12+ it uses "dynamic color" (the palette derived from the user's wallpaper); on older
* versions it falls back to a default light/dark scheme.
*/
@Composable
fun EncoreTheme(
@@ -22,15 +22,21 @@ fun EncoreTheme(
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
darkTheme -> {
darkColorScheme()
}
else -> {
lightColorScheme()
}
}
MaterialTheme(
colorScheme = colorScheme,
@@ -13,31 +13,35 @@ import kotlinx.coroutines.flow.map
// A single process-wide DataStore instance, tied to the application context via
// this property delegate (the recommended pattern — creating more than one
// DataStore for the same file throws).
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "connection_settings")
private val Context.dataStore: DataStore<Preferences> by
preferencesDataStore(name = "connection_settings")
/**
* Reads and writes the persisted [ConnectionSettings] using Preferences
* DataStore. Reads come back as a [Flow] that emits on every change; the write
* is a `suspend` transaction.
* Reads and writes the persisted [ConnectionSettings] using Preferences DataStore. Reads come back
* as a [Flow] that emits on every change; the write is a `suspend` transaction.
*/
class SettingsRepository(private val context: Context) {
private object Keys {
val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port")
}
/**
* The saved settings, or `null` if the user has never connected (no host
* persisted yet). Callers use the null case to show first-run UI / decide
* whether to auto-connect.
* The saved settings, or `null` if the user has never connected (no host persisted yet).
* Callers use the null case to show first-run UI / decide whether to auto-connect.
*/
val settingsOrNull: Flow<ConnectionSettings?> = context.dataStore.data.map { prefs ->
val host = prefs[Keys.HOST] ?: return@map null
ConnectionSettings(host = host, port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port)
}
val settingsOrNull: Flow<ConnectionSettings?> =
context.dataStore.data.map { prefs ->
val host = prefs[Keys.HOST] ?: return@map null
ConnectionSettings(
host = host,
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
)
}
/** Same as [settingsOrNull] but falling back to [ConnectionSettings.DEFAULT] for form prefill. */
/**
* Same as [settingsOrNull] but falling back to [ConnectionSettings.DEFAULT] for form prefill.
*/
val settings: Flow<ConnectionSettings> = settingsOrNull.map { it ?: ConnectionSettings.DEFAULT }
suspend fun save(settings: ConnectionSettings) {
@@ -5,6 +5,7 @@ import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.encore.mpd.model.MpdStatistics
import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.encore.mpd.model.PlayerState
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -22,32 +23,31 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.io.IOException
/**
* The high-level, coroutine-driven MPD client. It owns **two** connections, the
* pattern MALP and other real clients use:
* The high-level, coroutine-driven MPD client. It owns **two** connections, the pattern MALP and
* other real clients use:
*
* - a **command** connection, guarded by a [Mutex], for request/response
* commands (play, setvol, playlistinfo, …);
* - an **idle** connection parked in the blocking `idle` command, so the server
* can push change notifications. Each `changed:` event triggers a refresh on
* the command connection, which flows out through [status]/[currentSong].
* - a **command** connection, guarded by a [Mutex], for request/response commands (play, setvol,
* playlistinfo, …);
* - an **idle** connection parked in the blocking `idle` command, so the server can push change
* notifications. Each `changed:` event triggers a refresh on the command connection, which flows
* out through [status]/[currentSong].
*
* **Resilience.** A quiet command connection would otherwise be reaped by MPD's
* `connection_timeout`, so a periodic [keepalive] `ping` keeps it warm and
* detects death early. Any transport failure triggers a transparent
* [reconnect][triggerReconnect] (staying on the player, not bouncing the user to
* the connect screen); only repeated failures surface as
* [MpdConnectionState.Error]. Server-side `ACK` errors are *not* treated as
* connection failures.
* `connection_timeout`, so a periodic [keepalive] `ping` keeps it warm and detects death early. Any
* transport failure triggers a transparent [reconnect][triggerReconnect] (staying on the player,
* not bouncing the user to the connect screen); only repeated failures surface as
* [MpdConnectionState.Error]. Server-side `ACK` errors are *not* treated as connection failures.
*
* A [connectionFactory] is injectable so tests can supply fake transports.
*/
class MpdClient(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val connectionFactory: (host: String, port: Int, readTimeoutMs: Int) -> MpdConnection =
{ host, port, readTimeoutMs -> MpdConnection.connect(host, port, readTimeoutMs = readTimeoutMs) },
{ host, port, readTimeoutMs ->
MpdConnection.connect(host, port, readTimeoutMs = readTimeoutMs)
},
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
@@ -64,10 +64,10 @@ class MpdClient(
private var password: String? = null
/** True while an intentional [disconnect]/[shutdown] is in progress. */
@Volatile
private var shuttingDown = false
@Volatile private var shuttingDown = false
private val _connectionState = MutableStateFlow<MpdConnectionState>(MpdConnectionState.Disconnected)
private val _connectionState =
MutableStateFlow<MpdConnectionState>(MpdConnectionState.Disconnected)
val connectionState = _connectionState.asStateFlow()
private val _status = MutableStateFlow<MpdStatus?>(null)
@@ -79,11 +79,14 @@ class MpdClient(
// --- Lifecycle ----------------------------------------------------------
/**
* Open both connections, authenticate, prime the initial state, and start
* the idle + keepalive loops. On failure the state becomes
* [MpdConnectionState.Error] and the call throws.
* Open both connections, authenticate, prime the initial state, and start the idle + keepalive
* loops. On failure the state becomes [MpdConnectionState.Error] and the call throws.
*/
suspend fun connect(host: String, port: Int = MpdConnection.DEFAULT_PORT, password: String? = null) {
suspend fun connect(
host: String,
port: Int = MpdConnection.DEFAULT_PORT,
password: String? = null,
) {
disconnect() // ensure a clean slate if re-connecting
shuttingDown = false
this.host = host
@@ -159,31 +162,40 @@ class MpdClient(
// loop, keeping us in lockstep with the server (and other clients).
suspend fun play() = run(MpdCommands.play())
suspend fun playPos(pos: Int) = run(MpdCommands.playPos(pos))
suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))
suspend fun stop() = run(MpdCommands.stop())
suspend fun clearQueue() = run(MpdCommands.clear())
/** Remove a queue entry by its stable song id. */
suspend fun removeQueueItem(songId: Int) = run(MpdCommands.deleteId(songId))
/** Insert a single track at an absolute queue position (used to undo a removal). */
suspend fun addTrackAt(uri: String, position: Int) = run(MpdCommands.add(uri, position.toString()))
suspend fun addTrackAt(
uri: String,
position: Int,
) = run(MpdCommands.add(uri, position.toString()))
/** Append a single track to the end of the queue; playback is untouched. */
suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))
/**
* Insert a single track right after the current one so it plays next. Falls
* back to a plain append when nothing is playing — no current song to anchor
* the relative `"+0"` position to.
* Insert a single track right after the current one so it plays next. Falls back to a plain
* append when nothing is playing — no current song to anchor the relative `"+0"` position to.
*/
suspend fun playTrackNext(uri: String) = withCommand { conn ->
val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null
conn.execute(MpdCommands.add(uri, position = if (playing) "+0" else null))
}
suspend fun next() = run(MpdCommands.next())
suspend fun previous() = run(MpdCommands.previous())
suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))
/** Flip play/pause based on the latest known [status]. */
@@ -193,10 +205,15 @@ class MpdClient(
}
suspend fun seekCurrent(seconds: Double) = run(MpdCommands.seekCurrent(seconds))
suspend fun setVolume(volume: Int) = run(MpdCommands.setVolume(volume))
suspend fun setRepeat(on: Boolean) = run(MpdCommands.repeat(on))
suspend fun setRandom(on: Boolean) = run(MpdCommands.random(on))
suspend fun setSingle(on: Boolean) = run(MpdCommands.single(on))
suspend fun setConsume(on: Boolean) = run(MpdCommands.consume(on))
/** Fetch the current play queue. */
@@ -215,31 +232,47 @@ class MpdClient(
}
/** Replace the queue with an album and start playing it. */
suspend fun playAlbum(album: String, albumArtist: String?) = withCommand { conn ->
suspend fun playAlbum(
album: String,
albumArtist: String?,
) = withCommand { conn ->
conn.execute(MpdCommands.clear())
conn.execute(MpdCommands.findAddAlbum(album, albumArtist))
conn.execute(MpdCommands.play())
}
/** Append an album's tracks to the end of the queue; playback is untouched. */
suspend fun queueAlbum(album: String, albumArtist: String?) = withCommand { conn ->
suspend fun queueAlbum(
album: String,
albumArtist: String?,
) = withCommand { conn ->
conn.execute(MpdCommands.findAddAlbum(album, albumArtist))
}
/**
* Insert an album right after the current track so it plays next. Falls back
* to a plain append when nothing is playing — there is no current song for the
* relative `"+0"` position to anchor to.
* Insert an album right after the current track so it plays next. Falls back to a plain append
* when nothing is playing — there is no current song for the relative `"+0"` position to anchor
* to.
*/
suspend fun playAlbumNext(album: String, albumArtist: String?) = withCommand { conn ->
suspend fun playAlbumNext(
album: String,
albumArtist: String?,
) = withCommand { conn ->
val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null
conn.execute(MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null))
conn.execute(
MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null)
)
}
/** Every track of an album, ordered by disc then track number. */
suspend fun albumTracks(album: String, albumArtist: String?): List<MpdSong> = withCommand { conn ->
conn.execute(MpdCommands.findAlbumTracks(album, albumArtist))
.split().mapNotNull { MpdSong.from(it) }
suspend fun albumTracks(
album: String,
albumArtist: String?,
): List<MpdSong> = withCommand { conn ->
conn
.execute(MpdCommands.findAlbumTracks(album, albumArtist))
.split()
.mapNotNull { MpdSong.from(it) }
.sortedWith(compareBy({ it.disc.leadingInt() }, { it.track.leadingInt() }))
}
@@ -247,29 +280,44 @@ class MpdClient(
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
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? =
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? {
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 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)
@@ -287,8 +335,8 @@ class MpdClient(
}
/**
* Serialize access to the command connection. A server `ACK` is rethrown as-is
* (the connection is fine); a transport failure triggers a reconnect.
* Serialize access to the command connection. A server `ACK` is rethrown as-is (the connection
* is fine); a transport failure triggers a reconnect.
*/
private suspend fun <T> withCommand(block: (MpdConnection) -> T): T =
withContext(ioDispatcher) {
@@ -334,7 +382,10 @@ class MpdClient(
}
private suspend fun borrowArtConnection(): MpdConnection {
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }?.let { return it }
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)
@@ -345,36 +396,40 @@ class MpdClient(
}
private fun returnArtConnection(conn: MpdConnection) {
val kept = synchronized(artPoolLock) {
if (idleArtConnections.size < ART_POOL_SIZE) {
idleArtConnections.addLast(conn)
true
} else {
false
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
}
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 ->
val (statusResp, songResp) = conn.executeList(
MpdCommands.status(),
MpdCommands.currentSong(),
)
MpdStatus.from(statusResp.toMap()) to MpdSong.from(songResp.toMap())
}
val (status, song) =
withCommand { conn ->
val (statusResp, songResp) =
conn.executeList(
MpdCommands.status(),
MpdCommands.currentSong(),
)
MpdStatus.from(statusResp.toMap()) to MpdSong.from(songResp.toMap())
}
_status.value = status
_currentSong.value = song
}
@@ -384,9 +439,10 @@ class MpdClient(
idleJob = scope.launch {
try {
while (isActive) {
val changed = withContext(ioDispatcher) {
idle.execute(MpdCommands.idle()).getAll("changed")
}
val changed =
withContext(ioDispatcher) {
idle.execute(MpdCommands.idle()).getAll("changed")
}
if (changed.any { it in REFRESHING_SUBSYSTEMS }) refresh()
}
} catch (e: IOException) {
@@ -396,8 +452,8 @@ class MpdClient(
}
/**
* Periodic `ping` so MPD's `connection_timeout` never reaps our (often quiet)
* command connection, and so a dead socket is noticed promptly.
* Periodic `ping` so MPD's `connection_timeout` never reaps our (often quiet) command
* connection, and so a dead socket is noticed promptly.
*/
private fun startKeepalive() {
keepaliveJob = scope.launch {
@@ -455,12 +511,10 @@ class MpdClient(
private fun closeConnectionsQuietly() {
try {
idleConn?.close()
} catch (_: IOException) {
}
} catch (_: IOException) {}
try {
commandConn?.close()
} catch (_: IOException) {
}
} catch (_: IOException) {}
idleConn = null
commandConn = null
closeArtConnections()
@@ -485,8 +539,8 @@ class MpdClient(
}
/**
* Leading integer of a `track`/`disc` tag for sorting — these can carry values
* like `"3/12"`, so we read the digits up front. Missing/blank tags sort last.
* Leading integer of a `track`/`disc` tag for sorting — these can carry values like `"3/12"`, so we
* read the digits up front. Missing/blank tags sort last.
*/
private fun String?.leadingInt(): Int =
this?.takeWhile { it.isDigit() }?.toIntOrNull() ?: Int.MAX_VALUE
@@ -1,32 +1,36 @@
package ca.ksamad.encore.mpd
/**
* Typed builders for the command lines we send. These return the assembled,
* properly-quoted string (no newline) that gets handed to
* [MpdConnection.execute]; keeping them pure makes both the argument quoting and
* the exact wire form unit-testable without any I/O.
* Typed builders for the command lines we send. These return the assembled, properly-quoted string
* (no newline) that gets handed to [MpdConnection.execute]; keeping them pure makes both the
* argument quoting and the exact wire form unit-testable without any I/O.
*
* This is deliberately a small, growing subset — the transport controls, option
* toggles, and status queries a remote UI needs first. Database/queue/playlist
* builders get added as those features land.
* This is deliberately a small, growing subset — the transport controls, option toggles, and status
* queries a remote UI needs first. Database/queue/playlist builders get added as those features
* land.
*/
object MpdCommands {
// --- Status queries -----------------------------------------------------
fun status() = MpdProtocol.command("status")
fun currentSong() = MpdProtocol.command("currentsong")
fun stats() = MpdProtocol.command("stats")
/**
* Block until one of [subsystems] changes (all of them if none given). Only
* legal on a connection dedicated to idling — never inside a command list.
* Block until one of [subsystems] changes (all of them if none given). Only legal on a
* connection dedicated to idling — never inside a command list.
*/
fun idle(vararg subsystems: String) =
if (subsystems.isEmpty()) MpdProtocol.command("idle")
else MpdProtocol.command("idle", *subsystems)
if (subsystems.isEmpty()) {
MpdProtocol.command("idle")
} else {
MpdProtocol.command("idle", *subsystems)
}
fun noidle() = MpdProtocol.command("noidle")
fun ping() = MpdProtocol.command("ping")
// --- Authentication -----------------------------------------------------
@@ -36,45 +40,56 @@ object MpdCommands {
// --- Transport ----------------------------------------------------------
fun play() = MpdProtocol.command("play")
fun playPos(pos: Int) = MpdProtocol.command("play", pos.toString())
fun playId(songId: Int) = MpdProtocol.command("playid", songId.toString())
fun stop() = MpdProtocol.command("stop")
fun next() = MpdProtocol.command("next")
fun previous() = MpdProtocol.command("previous")
/** `pause 1`/`pause 0`; with no state MPD toggles, but we send it explicitly. */
fun pause(paused: Boolean) =
MpdProtocol.command("pause", if (paused) "1" else "0")
fun pause(paused: Boolean) = MpdProtocol.command("pause", if (paused) "1" else "0")
/** Seek to [seconds] within the currently playing song. */
fun seekCurrent(seconds: Double) =
MpdProtocol.command("seekcur", seconds.toString())
fun seekCurrent(seconds: Double) = MpdProtocol.command("seekcur", seconds.toString())
// --- Options / mixer ----------------------------------------------------
fun setVolume(volume: Int) =
MpdProtocol.command("setvol", volume.coerceIn(0, 100).toString())
fun setVolume(volume: Int) = MpdProtocol.command("setvol", volume.coerceIn(0, 100).toString())
fun repeat(on: Boolean) = MpdProtocol.command("repeat", if (on) "1" else "0")
fun random(on: Boolean) = MpdProtocol.command("random", if (on) "1" else "0")
fun single(on: Boolean) = MpdProtocol.command("single", if (on) "1" else "0")
fun consume(on: Boolean) = MpdProtocol.command("consume", if (on) "1" else "0")
// --- Queue --------------------------------------------------------------
fun playlistInfo() = MpdProtocol.command("playlistinfo")
fun clear() = MpdProtocol.command("clear")
/**
* Add a single file/URI to the queue. With no [position] it appends to the
* end; otherwise the value is passed as `add`'s positional argument — `"+0"`
* inserts right after the currently playing song ("play next").
* Add a single file/URI to the queue. With no [position] it appends to the end; otherwise the
* value is passed as `add`'s positional argument — `"+0"` inserts right after the currently
* playing song ("play next").
*/
fun add(uri: String, position: String? = null) =
fun add(
uri: String,
position: String? = null,
) =
if (position == null) {
MpdProtocol.command("add", uri)
} else {
MpdProtocol.command("add", uri, position)
}
fun deleteId(songId: Int) = MpdProtocol.command("deleteid", songId.toString())
// --- Database / library -------------------------------------------------
@@ -85,22 +100,36 @@ object MpdCommands {
/**
* Append every track of an album to the queue (optionally scoped to an artist).
*
* When [position] is given it is passed through as `findadd`'s `position`
* argument, controlling where the tracks land. MPD accepts an absolute index
* or a relative one — `"+0"` inserts right after the currently playing song
* (i.e. "play next"). Omit it to append to the end of the queue.
* When [position] is given it is passed through as `findadd`'s `position` argument, controlling
* where the tracks land. MPD accepts an absolute index or a relative one — `"+0"` inserts right
* after the currently playing song (i.e. "play next"). Omit it to append to the end of the
* queue.
*/
fun findAddAlbum(album: String, albumArtist: String?, position: String? = null): String {
fun findAddAlbum(
album: String,
albumArtist: String?,
position: String? = null,
): String {
val args = buildList {
add("album"); add(album)
if (!albumArtist.isNullOrEmpty()) { add("albumartist"); add(albumArtist) }
if (position != null) { add("position"); add(position) }
add("album")
add(album)
if (!albumArtist.isNullOrEmpty()) {
add("albumartist")
add(albumArtist)
}
if (position != null) {
add("position")
add(position)
}
}
return MpdProtocol.command("findadd", *args.toTypedArray())
}
/** Every track of an album (optionally scoped to an artist), in database order. */
fun findAlbumTracks(album: String, albumArtist: String?) =
fun findAlbumTracks(
album: String,
albumArtist: String?,
) =
if (albumArtist.isNullOrEmpty()) {
MpdProtocol.command("find", "album", album)
} else {
@@ -108,7 +137,10 @@ object MpdCommands {
}
/** First track of an album — used to resolve a URI for album-art lookup. */
fun findFirstTrack(album: String, albumArtist: String?) =
fun findFirstTrack(
album: String,
albumArtist: String?,
) =
if (albumArtist.isNullOrEmpty()) {
MpdProtocol.command("find", "album", album, "window", "0:1")
} else {
@@ -118,10 +150,16 @@ object MpdCommands {
// --- 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())
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())
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())
@@ -11,36 +11,33 @@ import java.net.InetSocketAddress
import java.net.Socket
/**
* A single synchronous MPD connection: the raw text protocol over a byte
* stream. One command is written, one response is read back; calls are **not**
* thread-safe and must be serialized by the caller (a higher-level client will
* own a [MpdConnection] behind a mutex, and a second one parked in `idle`).
* A single synchronous MPD connection: the raw text protocol over a byte stream. One command is
* written, one response is read back; calls are **not** thread-safe and must be serialized by the
* caller (a higher-level client will own a [MpdConnection] behind a mutex, and a second one parked
* in `idle`).
*
* The transport is an [InputStream]/[OutputStream] pair rather than a [Socket]
* so the protocol can be exercised against in-memory streams in tests. Use
* [connect] for a real TCP connection.
* The transport is an [InputStream]/[OutputStream] pair rather than a [Socket] so the protocol can
* be exercised against in-memory streams in tests. Use [connect] for a real TCP connection.
*
* Reading is done at the byte level (not via a [java.io.Reader]) because
* responses can interleave UTF-8 text lines with raw binary payloads, and a
* buffered character reader would happily swallow bytes past a line boundary.
* Reading is done at the byte level (not via a [java.io.Reader]) because responses can interleave
* UTF-8 text lines with raw binary payloads, and a buffered character reader would happily swallow
* bytes past a line boundary.
*/
class MpdConnection(
private val input: InputStream,
private val output: OutputStream,
private val closer: Closeable? = null,
) : Closeable {
/** Protocol version from the greeting, e.g. `0.23.5`. Null until [handshake]. */
var protocolVersion: String? = null
private set
/**
* Read and validate the server greeting (`OK MPD <version>`). Must be called
* exactly once, before any command. Returns the protocol version.
* Read and validate the server greeting (`OK MPD <version>`). Must be called exactly once,
* before any command. Returns the protocol version.
*/
fun handshake(): String {
val line = readLine()
?: throw MpdConnectionException("connection closed before greeting")
val line = readLine() ?: throw MpdConnectionException("connection closed before greeting")
if (!line.startsWith(MpdProtocol.GREETING_PREFIX)) {
throw MpdConnectionException("unexpected greeting: $line")
}
@@ -50,8 +47,8 @@ class MpdConnection(
}
/**
* Send one command line (name + already-assembled string, no newline) and
* read its response up to the terminating `OK`.
* Send one command line (name + already-assembled string, no newline) and read its response up
* to the terminating `OK`.
*
* @throws MpdAckException if the server replied `ACK …`.
* @throws MpdConnectionException on EOF or a malformed response.
@@ -62,14 +59,15 @@ class MpdConnection(
}
/** Convenience: [MpdProtocol.command] + [execute]. */
fun execute(name: String, vararg args: String): MpdResponse =
execute(MpdProtocol.command(name, *args))
fun execute(
name: String,
vararg args: String,
): MpdResponse = execute(MpdProtocol.command(name, *args))
/**
* Run several commands as a single batch (`command_list_ok_begin`), returning
* one [MpdResponse] per command in order. One network round-trip instead of
* N — worth it on a slow server. Each command's response is delimited by
* `list_OK`, and the batch ends with a final `OK`.
* Run several commands as a single batch (`command_list_ok_begin`), returning one [MpdResponse]
* per command in order. One network round-trip instead of N — worth it on a slow server. Each
* command's response is delimited by `list_OK`, and the batch ends with a final `OK`.
*/
fun executeList(vararg commandLines: String): List<MpdResponse> {
val payload = buildString {
@@ -97,25 +95,29 @@ class MpdConnection(
val values = ArrayList<Pair<String, String>>()
var binary: ByteArray? = null
while (true) {
val line = readLine()
?: throw MpdConnectionException("connection closed mid-response")
val line = readLine() ?: throw MpdConnectionException("connection closed mid-response")
when {
// OK ends a command; list_OK ends one command within a command list.
line == MpdProtocol.OK || line == MpdProtocol.LIST_OK ->
line == MpdProtocol.OK || line == MpdProtocol.LIST_OK -> {
return MpdResponse(values, binary)
line.startsWith("ACK ") ->
}
line.startsWith("ACK ") -> {
throw MpdAckException.parse(line)
?: MpdConnectionException("malformed ACK: $line")
}
else -> {
val sep = line.indexOf(": ")
if (sep < 0) continue // tolerate stray lines rather than fail
val key = line.substring(0, sep)
val value = line.substring(sep + 2)
if (key == "binary") {
binary = readBinary(
value.toIntOrNull()
?: throw MpdConnectionException("bad binary size: $value")
)
binary =
readBinary(
value.toIntOrNull()
?: throw MpdConnectionException("bad binary size: $value")
)
} else {
values.add(key to value)
}
@@ -143,7 +145,7 @@ class MpdConnection(
var off = 0
while (off < size) {
val n = input.read(data, off, size - off)
if (n == -1) throw MpdConnectionException("EOF after ${off}/$size binary bytes")
if (n == -1) throw MpdConnectionException("EOF after $off/$size binary bytes")
off += n
}
input.read() // trailing newline that follows the binary block
@@ -154,20 +156,19 @@ class MpdConnection(
// Best-effort: closing the socket tears down both streams.
try {
closer?.close() ?: output.close()
} catch (_: IOException) {
}
} catch (_: IOException) {}
}
companion object {
const val DEFAULT_PORT = 6600
/**
* Open a TCP connection to an MPD server and complete the greeting
* handshake. Blocking — call off the main thread.
* Open a TCP connection to an MPD server and complete the greeting handshake. Blocking —
* call off the main thread.
*
* @param connectTimeoutMs socket connect timeout.
* @param readTimeoutMs `SO_TIMEOUT`; 0 means block forever (needed for
* the `idle` connection, which parks indefinitely).
* @param readTimeoutMs `SO_TIMEOUT`; 0 means block forever (needed for the `idle`
* connection, which parks indefinitely).
*/
fun connect(
host: String,
@@ -180,11 +181,12 @@ class MpdConnection(
socket.connect(InetSocketAddress(host, port), connectTimeoutMs)
socket.soTimeout = readTimeoutMs
socket.tcpNoDelay = true
val conn = MpdConnection(
input = BufferedInputStream(socket.getInputStream()),
output = BufferedOutputStream(socket.getOutputStream()),
closer = socket,
)
val conn =
MpdConnection(
input = BufferedInputStream(socket.getInputStream()),
output = BufferedOutputStream(socket.getOutputStream()),
closer = socket,
)
conn.handshake()
return conn
} catch (e: IOException) {
@@ -3,29 +3,31 @@ package ca.ksamad.encore.mpd
import java.io.IOException
/** Base type for every failure the MPD layer can raise. */
sealed class MpdException(message: String, cause: Throwable? = null) :
IOException(message, cause)
sealed class MpdException(
message: String,
cause: Throwable? = null,
) : IOException(message, cause)
/**
* A transport-level problem: the socket closed, the greeting was malformed, an
* EOF arrived mid-response, etc. These are the failures that mean "the
* connection can no longer be trusted", as opposed to a command the server
* simply rejected.
* A transport-level problem: the socket closed, the greeting was malformed, an EOF arrived
* mid-response, etc. These are the failures that mean "the connection can no longer be trusted", as
* opposed to a command the server simply rejected.
*/
class MpdConnectionException(message: String, cause: Throwable? = null) :
MpdException(message, cause)
class MpdConnectionException(
message: String,
cause: Throwable? = null,
) : MpdException(message, cause)
/**
* The server understood us but refused the command, i.e. it replied with an
* `ACK` line. The wire format is:
*
* The server understood us but refused the command, i.e. it replied with an `ACK` line. The wire
* format is:
* ```
* ACK [error@command_listNum] {current_command} message_text
* ```
*
* where `error` is one of MPD's numeric `ACK_ERROR_*` codes (see [Code]),
* `command_listNum` is the 0-based offset of the failing command inside a
* command list (0 for a bare command), and `current_command` names it.
* where `error` is one of MPD's numeric `ACK_ERROR_*` codes (see [Code]), `command_listNum` is the
* 0-based offset of the failing command inside a command list (0 for a bare command), and
* `current_command` names it.
*/
class MpdAckException(
val code: Int,
@@ -33,7 +35,6 @@ class MpdAckException(
val command: String,
val serverMessage: String,
) : MpdException("ACK [$code@$commandListNum] {$command} $serverMessage") {
/** MPD's `ACK_ERROR_*` constants, for callers that want to branch on them. */
object Code {
const val NOT_LIST = 1
@@ -58,8 +59,8 @@ class MpdAckException(
private val PATTERN = Regex("""ACK \[(\d+)@(\d+)\] \{([^}]*)\} ?(.*)""")
/**
* Parse a raw `ACK …` response line. Returns `null` if [line] is not a
* well-formed ACK, so the caller can decide how to treat garbage.
* Parse a raw `ACK …` response line. Returns `null` if [line] is not a well-formed ACK, so
* the caller can decide how to treat garbage.
*/
fun parse(line: String): MpdAckException? {
val m = PATTERN.matchEntire(line) ?: return null
@@ -1,12 +1,11 @@
package ca.ksamad.encore.mpd
/**
* Pure, connection-independent helpers for speaking the MPD text protocol:
* argument quoting and command-string assembly. Kept separate from
* [MpdConnection] so it can be unit-tested without any I/O.
* Pure, connection-independent helpers for speaking the MPD text protocol: argument quoting and
* command-string assembly. Kept separate from [MpdConnection] so it can be unit-tested without any
* I/O.
*/
object MpdProtocol {
/** Every server greeting starts with this, followed by the protocol version. */
const val GREETING_PREFIX = "OK MPD "
@@ -17,11 +16,10 @@ object MpdProtocol {
const val LIST_OK = "list_OK"
/**
* Quote a single command argument. MPD's tokenizer treats a bare token as
* ending at the next whitespace, so any argument is wrapped in double quotes
* with embedded `"` and `\` backslash-escaped. Always quoting (even numbers
* and empty strings) is accepted by the server and keeps callers from having
* to reason about which values are "safe".
* Quote a single command argument. MPD's tokenizer treats a bare token as ending at the next
* whitespace, so any argument is wrapped in double quotes with embedded `"` and `\`
* backslash-escaped. Always quoting (even numbers and empty strings) is accepted by the server
* and keeps callers from having to reason about which values are "safe".
*/
fun quote(arg: String): String {
val sb = StringBuilder(arg.length + 2)
@@ -35,11 +33,13 @@ object MpdProtocol {
}
/**
* Assemble a full command line (without the trailing newline) from a command
* name and its arguments. The name is emitted verbatim; every argument is
* [quote]d.
* Assemble a full command line (without the trailing newline) from a command name and its
* arguments. The name is emitted verbatim; every argument is [quote]d.
*/
fun command(name: String, vararg args: String): String {
fun command(
name: String,
vararg args: String,
): String {
if (args.isEmpty()) return name
return buildString {
append(name)
@@ -1,31 +1,26 @@
package ca.ksamad.encore.mpd
/**
* The parsed result of a single successful command: the ordered `key: value`
* lines the server sent before its `OK`, plus an optional [binary] payload for
* commands like `albumart`/`readpicture`.
* The parsed result of a single successful command: the ordered `key: value` lines the server sent
* before its `OK`, plus an optional [binary] payload for commands like `albumart`/`readpicture`.
*
* Order is preserved because several commands (e.g. `playlistinfo`, `lsinfo`)
* return repeated blocks that are only separable by position — a new object
* begins each time a delimiting key such as `file` reappears. Use [split] to
* chop such a response into per-object maps.
* Order is preserved because several commands (e.g. `playlistinfo`, `lsinfo`) return repeated
* blocks that are only separable by position — a new object begins each time a delimiting key such
* as `file` reappears. Use [split] to chop such a response into per-object maps.
*/
class MpdResponse(
val values: List<Pair<String, String>>,
val binary: ByteArray? = null,
) {
/** First value for [key], or `null` if absent. */
operator fun get(key: String): String? =
values.firstOrNull { it.first == key }?.second
operator fun get(key: String): String? = values.firstOrNull { it.first == key }?.second
/** All values for [key], in order. */
fun getAll(key: String): List<String> =
values.filter { it.first == key }.map { it.second }
fun getAll(key: String): List<String> = values.filter { it.first == key }.map { it.second }
/**
* Flatten to a map of first-seen values. Safe for commands whose keys are
* unique (`status`, `currentsong`, `stats`); lossy for repeated blocks —
* use [split] there instead.
* Flatten to a map of first-seen values. Safe for commands whose keys are unique (`status`,
* `currentsong`, `stats`); lossy for repeated blocks — use [split] there instead.
*/
fun toMap(): Map<String, String> {
val out = LinkedHashMap<String, String>(values.size)
@@ -34,9 +29,9 @@ class MpdResponse(
}
/**
* Split a multi-object response into one map per object. A new object starts
* at every occurrence of [delimiter] (default `file`, the first key MPD emits
* for a song/file entry). Lines before the first delimiter are ignored.
* Split a multi-object response into one map per object. A new object starts at every
* occurrence of [delimiter] (default `file`, the first key MPD emits for a song/file entry).
* Lines before the first delimiter are ignored.
*/
fun split(delimiter: String = "file"): List<Map<String, String>> {
val result = ArrayList<Map<String, String>>()
@@ -9,9 +9,9 @@ data class MpdAlbum(
) {
companion object {
/**
* Parse the response of `list album group albumartist`. MPD emits an
* `AlbumArtist:` line followed by the `Album:` lines belonging to it, so
* we track the current artist and attach it to each album.
* Parse the response of `list album group albumartist`. MPD emits an `AlbumArtist:` line
* followed by the `Album:` lines belonging to it, so we track the current artist and attach
* it to each album.
*/
fun listFrom(response: MpdResponse): List<MpdAlbum> {
val albums = ArrayList<MpdAlbum>()
@@ -1,13 +1,12 @@
package ca.ksamad.encore.mpd.model
/**
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`,
* `playlistinfo`, `find`, `lsinfo`, etc. [uri] (the `file` key) is the only
* required field; every tag is optional because the server only sends tags the
* file actually has.
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`, `playlistinfo`,
* `find`, `lsinfo`, etc. [uri] (the `file` key) is the only required field; every tag is optional
* because the server only sends tags the file actually has.
*
* Tags are kept as raw strings — `track`/`disc` can carry values like `"3/12"`,
* and normalising them is a display concern left to the UI layer.
* Tags are kept as raw strings — `track`/`disc` can carry values like `"3/12"`, and normalising
* them is a display concern left to the UI layer.
*/
data class MpdSong(
val uri: String,
@@ -19,15 +18,14 @@ data class MpdSong(
val disc: String?,
val date: String?,
val genre: String?,
val duration: Double?, // from "duration" (fractional) or legacy "Time"
val pos: Int?, // queue position, present in queue listings
val id: Int?, // stable queue id, present in queue listings
val duration: Double?, // from "duration" (fractional) or legacy "Time"
val pos: Int?, // queue position, present in queue listings
val id: Int?, // stable queue id, present in queue listings
) {
companion object {
/**
* Parse one song from a metadata map. Returns `null` when there is no
* `file` key (i.e. this block is not a song — e.g. a `directory` entry
* in an `lsinfo` listing).
* Parse one song from a metadata map. Returns `null` when there is no `file` key (i.e. this
* block is not a song — e.g. a `directory` entry in an `lsinfo` listing).
*/
fun from(values: Map<String, String>): MpdSong? {
val uri = values["file"] ?: return null
@@ -41,8 +39,7 @@ data class MpdSong(
disc = values["Disc"],
date = values["Date"],
genre = values["Genre"],
duration = values["duration"]?.toDoubleOrNull()
?: values["Time"]?.toDoubleOrNull(),
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
pos = values["Pos"]?.toIntOrNull(),
id = values["Id"]?.toIntOrNull(),
)
@@ -1,8 +1,8 @@
package ca.ksamad.encore.mpd.model
/**
* Database/server statistics from a `stats` response. Durations are in seconds
* and the update timestamp is Unix epoch seconds, both as MPD reports them.
* Database/server statistics from a `stats` response. Durations are in seconds and the update
* timestamp is Unix epoch seconds, both as MPD reports them.
*/
data class MpdStatistics(
val artists: Int,
@@ -14,14 +14,15 @@ data class MpdStatistics(
val dbUpdateEpochSeconds: Long,
) {
companion object {
fun from(values: Map<String, String>): MpdStatistics = MpdStatistics(
artists = values["artists"]?.toIntOrNull() ?: 0,
albums = values["albums"]?.toIntOrNull() ?: 0,
songs = values["songs"]?.toIntOrNull() ?: 0,
uptimeSeconds = values["uptime"]?.toLongOrNull() ?: 0,
playtimeSeconds = values["playtime"]?.toLongOrNull() ?: 0,
dbPlaytimeSeconds = values["db_playtime"]?.toLongOrNull() ?: 0,
dbUpdateEpochSeconds = values["db_update"]?.toLongOrNull() ?: 0,
)
fun from(values: Map<String, String>): MpdStatistics =
MpdStatistics(
artists = values["artists"]?.toIntOrNull() ?: 0,
albums = values["albums"]?.toIntOrNull() ?: 0,
songs = values["songs"]?.toIntOrNull() ?: 0,
uptimeSeconds = values["uptime"]?.toLongOrNull() ?: 0,
playtimeSeconds = values["playtime"]?.toLongOrNull() ?: 0,
dbPlaytimeSeconds = values["db_playtime"]?.toLongOrNull() ?: 0,
dbUpdateEpochSeconds = values["db_update"]?.toLongOrNull() ?: 0,
)
}
}
@@ -2,63 +2,68 @@ package ca.ksamad.encore.mpd.model
/** Player transport state as reported by the `state` field of `status`. */
enum class PlayerState {
PLAY, PAUSE, STOP, UNKNOWN;
PLAY,
PAUSE,
STOP,
UNKNOWN;
companion object {
fun parse(raw: String?): PlayerState = when (raw) {
"play" -> PLAY
"pause" -> PAUSE
"stop" -> STOP
else -> UNKNOWN
}
fun parse(raw: String?): PlayerState =
when (raw) {
"play" -> PLAY
"pause" -> PAUSE
"stop" -> STOP
else -> UNKNOWN
}
}
}
/**
* A snapshot of the player, parsed from a `status` response. Fields absent from
* the response (e.g. `song`/`elapsed` while stopped) are `null`.
* A snapshot of the player, parsed from a `status` response. Fields absent from the response (e.g.
* `song`/`elapsed` while stopped) are `null`.
*
* See the MPD protocol docs for field semantics. Only the fields a remote UI
* actually drives are surfaced here; more can be added as needed.
* See the MPD protocol docs for field semantics. Only the fields a remote UI actually drives are
* surfaced here; more can be added as needed.
*/
data class MpdStatus(
val volume: Int?, // 0..100; null when MPD reports -1 (output closed / volume unavailable)
val volume: Int?, // 0..100; null when MPD reports -1 (output closed / volume unavailable)
val repeat: Boolean,
val random: Boolean,
val single: Boolean, // true for "1" or "oneshot"
val single: Boolean, // true for "1" or "oneshot"
val consume: Boolean,
val playlistVersion: Int?, // queue version; bumps on every queue change
val playlistLength: Int, // number of songs in the queue
val playlistVersion: Int?, // queue version; bumps on every queue change
val playlistLength: Int, // number of songs in the queue
val state: PlayerState,
val song: Int?, // queue position of the current song
val songId: Int?, // stable id of the current song
val song: Int?, // queue position of the current song
val songId: Int?, // stable id of the current song
val nextSong: Int?,
val nextSongId: Int?,
val elapsed: Double?, // seconds into the current song (fractional)
val duration: Double?, // length of the current song in seconds
val bitrate: Int?, // instantaneous kbps
val audio: String?, // e.g. "44100:16:2"
val error: String?, // last player error, if any
val elapsed: Double?, // seconds into the current song (fractional)
val duration: Double?, // length of the current song in seconds
val bitrate: Int?, // instantaneous kbps
val audio: String?, // e.g. "44100:16:2"
val error: String?, // last player error, if any
) {
companion object {
fun from(values: Map<String, String>): MpdStatus = MpdStatus(
volume = values["volume"]?.toIntOrNull()?.takeIf { it >= 0 },
repeat = values["repeat"] == "1",
random = values["random"] == "1",
single = values["single"].let { it == "1" || it == "oneshot" },
consume = values["consume"] == "1",
playlistVersion = values["playlist"]?.toIntOrNull(),
playlistLength = values["playlistlength"]?.toIntOrNull() ?: 0,
state = PlayerState.parse(values["state"]),
song = values["song"]?.toIntOrNull(),
songId = values["songid"]?.toIntOrNull(),
nextSong = values["nextsong"]?.toIntOrNull(),
nextSongId = values["nextsongid"]?.toIntOrNull(),
elapsed = values["elapsed"]?.toDoubleOrNull(),
duration = values["duration"]?.toDoubleOrNull(),
bitrate = values["bitrate"]?.toIntOrNull(),
audio = values["audio"],
error = values["error"],
)
fun from(values: Map<String, String>): MpdStatus =
MpdStatus(
volume = values["volume"]?.toIntOrNull()?.takeIf { it >= 0 },
repeat = values["repeat"] == "1",
random = values["random"] == "1",
single = values["single"].let { it == "1" || it == "oneshot" },
consume = values["consume"] == "1",
playlistVersion = values["playlist"]?.toIntOrNull(),
playlistLength = values["playlistlength"]?.toIntOrNull() ?: 0,
state = PlayerState.parse(values["state"]),
song = values["song"]?.toIntOrNull(),
songId = values["songid"]?.toIntOrNull(),
nextSong = values["nextsong"]?.toIntOrNull(),
nextSongId = values["nextsongid"]?.toIntOrNull(),
elapsed = values["elapsed"]?.toDoubleOrNull(),
duration = values["duration"]?.toDoubleOrNull(),
bitrate = values["bitrate"]?.toIntOrNull(),
audio = values["audio"],
error = values["error"],
)
}
}
@@ -18,15 +18,16 @@ 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.
* 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 =
fun create(
context: PlatformContext,
manager: MpdConnectionManager,
): ImageLoader =
ImageLoader.Builder(context)
.components {
add(MpdArtKeyer(), MpdArtData::class)
@@ -41,13 +42,17 @@ object ArtImageLoader {
.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 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)
override fun key(
data: MpdArtData,
options: Options,
): String = cacheKey(data)
}
private class MpdArtFetcher(
@@ -55,7 +60,6 @@ object ArtImageLoader {
private val diskCache: DiskCache?,
private val fetch: suspend (MpdArtData) -> ByteArray?,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val key = cacheKey(data)
@@ -80,7 +84,10 @@ object ArtImageLoader {
)
}
private fun writeToDiskCache(key: String, bytes: ByteArray): DiskCache.Snapshot? {
private fun writeToDiskCache(
key: String,
bytes: ByteArray,
): DiskCache.Snapshot? {
val cache = diskCache ?: return null
val editor = cache.openEditor(key) ?: return null
return try {
@@ -96,22 +103,26 @@ object ArtImageLoader {
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,
)
): 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)
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)
}
}
}
@@ -1,9 +1,9 @@
package ca.ksamad.encore.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.
* 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
@@ -11,4 +11,7 @@ sealed interface MpdArtData
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
data class AlbumArt(
val album: String,
val albumArtist: String?,
) : MpdArtData
@@ -21,19 +21,16 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* App-scoped owner of the single [MpdClient]. Living at the [Application] level
* (not in a ViewModel) means the connection survives Activity recreation and
* keeps running while [PlaybackService] holds the app in the foreground — which
* is what makes the OS media session / notification / cast volume work when the
* user has left the UI.
* App-scoped owner of the single [MpdClient]. Living at the [Application] level (not in a
* ViewModel) means the connection survives Activity recreation and keeps running while
* [PlaybackService] holds the app in the foreground — which is what makes the OS media session /
* notification / cast volume work when the user has left the UI.
*
* Both the UI (via `PlayerViewModel`) and [PlaybackService] talk to this same
* instance: the UI observes the flows and issues commands; the service mirrors
* the flows into a `MediaSessionCompat` and routes media-button / volume-key
* callbacks back here.
* Both the UI (via `PlayerViewModel`) and [PlaybackService] talk to this same instance: the UI
* observes the flows and issues commands; the service mirrors the flows into a `MediaSessionCompat`
* and routes media-button / volume-key callbacks back here.
*/
class MpdConnectionManager(context: Context) {
private val appContext = context.applicationContext
private val client = MpdClient()
private val settingsRepo = SettingsRepository(appContext)
@@ -47,8 +44,8 @@ class MpdConnectionManager(context: Context) {
val serverHost: StateFlow<String?> = _serverHost
/** Persisted settings (defaulted) for seeding the connect form. */
val settings: StateFlow<ConnectionSettings> = settingsRepo.settings
.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
val settings: StateFlow<ConnectionSettings> =
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
// True until the startup auto-connect decision has been made.
private val _bootstrapping = MutableStateFlow(true)
@@ -56,8 +53,7 @@ class MpdConnectionManager(context: Context) {
// Optimistic volume target so rapid nudges accumulate instead of each reading
// the same stale server value; cleared once the server confirms it.
@Volatile
private var pendingVolume: Int? = null
@Volatile private var pendingVolume: Int? = null
// Volume writes are coalesced through a conflated channel + throttle so that
// holding a volume key (or dragging the OS remote-volume slider) sends at
@@ -91,15 +87,20 @@ class MpdConnectionManager(context: Context) {
connectionState.collect { st ->
when (st) {
is MpdConnectionState.Connected -> PlaybackService.start(appContext)
is MpdConnectionState.Disconnected,
is MpdConnectionState.Error -> PlaybackService.stop(appContext)
is MpdConnectionState.Connecting -> Unit
}
}
}
}
fun connect(host: String, port: Int) {
fun connect(
host: String,
port: Int,
) {
val trimmed = host.trim()
_serverHost.value = trimmed
scope.launch {
@@ -120,28 +121,48 @@ class MpdConnectionManager(context: Context) {
}
fun resume() = fire { pause(false) }
fun pause() = fire { pause(true) }
fun stop() = fire { stop() }
fun clearQueue() = fire { clearQueue() }
fun next() = fire { next() }
fun previous() = fire { previous() }
fun togglePlayPause() = fire { togglePause() }
fun seekTo(seconds: Double) = fire { seekCurrent(seconds) }
fun setVolume(volume: Int) {
// Coalesced + throttled by the volumeRequests consumer (see init).
volumeRequests.trySend(volume.coerceIn(0, 100))
}
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) }
fun playAlbum(
album: String,
albumArtist: String?,
) = fire { playAlbum(album, albumArtist) }
/** Append an album to the end of the queue without changing playback. */
fun queueAlbum(album: String, albumArtist: String?) = fire { queueAlbum(album, albumArtist) }
fun queueAlbum(
album: String,
albumArtist: String?,
) = fire { queueAlbum(album, albumArtist) }
/** Insert an album right after the current track so it plays next. */
fun playAlbumNext(album: String, albumArtist: String?) = fire { playAlbumNext(album, albumArtist) }
fun playAlbumNext(
album: String,
albumArtist: String?,
) = fire { playAlbumNext(album, albumArtist) }
/** Append a single track to the end of the queue. */
fun queueTrack(uri: String) = fire { queueTrack(uri) }
@@ -150,12 +171,14 @@ class MpdConnectionManager(context: Context) {
fun playTrackNext(uri: String) = fire { playTrackNext(uri) }
/** One-shot album track-list fetch; returns empty on any failure. */
suspend fun loadAlbumTracks(album: String, albumArtist: String?): List<MpdSong> =
suspend fun loadAlbumTracks(
album: String,
albumArtist: String?,
): List<MpdSong> =
runCatching { client.albumTracks(album, albumArtist) }.getOrDefault(emptyList())
/** One-shot server statistics fetch; returns null on any failure. */
suspend fun loadStatistics(): MpdStatistics? =
runCatching { client.statistics() }.getOrNull()
suspend fun loadStatistics(): MpdStatistics? = runCatching { client.statistics() }.getOrNull()
/** Jump to a queue entry by its stable song id. */
fun playQueueItem(songId: Int) = fire { playId(songId) }
@@ -164,29 +187,37 @@ class MpdConnectionManager(context: Context) {
fun removeQueueItem(songId: Int) = fire { removeQueueItem(songId) }
/** Re-insert a track at an absolute queue position (undo a removal). */
fun addTrackAt(uri: String, position: Int) = fire { addTrackAt(uri, position) }
fun addTrackAt(
uri: String,
position: Int,
) = fire { addTrackAt(uri, position) }
/** 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())
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()
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,
* in-app) or fall through to the device's own local volume.
* 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, in-app) or fall through to the
* device's own local volume.
*/
val isControllingVolume: Boolean
get() = connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
get() =
connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
/** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
fun nudgeVolume(up: Boolean) {
@@ -18,17 +18,17 @@ 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.encore.MainActivity
import ca.ksamad.encore.EncoreApplication
import ca.ksamad.encore.MainActivity
import ca.ksamad.encore.R
import ca.ksamad.encore.mpd.MpdConnectionState
import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.encore.mpd.model.PlayerState
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.toBitmap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -41,18 +41,17 @@ import kotlinx.coroutines.launch
/**
* Foreground service that mirrors the shared [MpdConnectionManager] into an OS
* [MediaSessionCompat]: it publishes now-playing metadata + playback state
* (driving the media notification, lock screen, and Quick Settings player),
* exposes transport controls, and — via [setPlaybackToRemote] with a
* [VolumeProviderCompat] — makes the hardware volume keys control the *server's*
* volume system-wide, cast-style, even when the app is in the background.
* [MediaSessionCompat]: it publishes now-playing metadata + playback state (driving the media
* notification, lock screen, and Quick Settings player), exposes transport controls, and — via
* [setPlaybackToRemote] with a [VolumeProviderCompat] — makes the hardware volume keys control the
* *server's* volume system-wide, cast-style, even when the app is in the background.
*
* Started/stopped by [MpdConnectionManager] as the connection comes and goes.
*/
class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val manager get() = (application as EncoreApplication).manager
private val manager
get() = (application as EncoreApplication).manager
private lateinit var session: MediaSessionCompat
private lateinit var volumeProvider: VolumeProviderCompat
@@ -62,33 +61,40 @@ class PlaybackService : Service() {
override fun onCreate() {
super.onCreate()
session = MediaSessionCompat(this, "Encore").apply {
setCallback(mediaCallback)
isActive = true
}
session =
MediaSessionCompat(this, "Encore").apply {
setCallback(mediaCallback)
isActive = true
}
// Remote (cast-style) volume: absolute 0..100, initialised from the server.
volumeProvider = object : VolumeProviderCompat(
VOLUME_CONTROL_ABSOLUTE,
MAX_VOLUME,
manager.status.value?.volume ?: 0,
) {
override fun onSetVolumeTo(volume: Int) {
manager.setVolume(volume)
currentVolume = volume
}
volumeProvider =
object :
VolumeProviderCompat(
VOLUME_CONTROL_ABSOLUTE,
MAX_VOLUME,
manager.status.value?.volume ?: 0,
) {
override fun onSetVolumeTo(volume: Int) {
manager.setVolume(volume)
currentVolume = volume
}
override fun onAdjustVolume(direction: Int) {
if (direction != 0) manager.nudgeVolume(up = direction > 0)
override fun onAdjustVolume(direction: Int) {
if (direction != 0) manager.nudgeVolume(up = direction > 0)
}
}
}
session.setPlaybackToRemote(volumeProvider)
createChannel()
observeState()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Deliver hardware / notification media-button presses to the session.
MediaButtonReceiver.handleIntent(session, intent)
startForeground(NOTIFICATION_ID, buildNotification())
@@ -102,14 +108,20 @@ class PlaybackService : Service() {
super.onDestroy()
}
private val mediaCallback = object : MediaSessionCompat.Callback() {
override fun onPlay() = manager.resume()
override fun onPause() = manager.pause()
override fun onStop() = manager.stop()
override fun onSkipToNext() = manager.next()
override fun onSkipToPrevious() = manager.previous()
override fun onSeekTo(pos: Long) = manager.seekTo(pos / 1000.0)
}
private val mediaCallback =
object : MediaSessionCompat.Callback() {
override fun onPlay() = manager.resume()
override fun onPause() = manager.pause()
override fun onStop() = manager.stop()
override fun onSkipToNext() = manager.next()
override fun onSkipToPrevious() = manager.previous()
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
@@ -135,7 +147,9 @@ class PlaybackService : Service() {
artUri = uri
artBitmap = if (uri != null) loadArtBitmap(uri) else null
if (artUri == uri) {
session.setMetadata(buildMetadata(manager.currentSong.value, manager.status.value))
session.setMetadata(
buildMetadata(manager.currentSong.value, manager.status.value)
)
postNotification()
}
}
@@ -151,30 +165,36 @@ class PlaybackService : Service() {
}
private suspend fun loadArtBitmap(uri: String): Bitmap? {
val result = SingletonImageLoader.get(applicationContext).execute(
ImageRequest.Builder(applicationContext).data(SongArt(uri)).build(),
)
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 ?: "")
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song?.artist ?: "")
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)
private fun buildMetadata(
song: MpdSong?,
status: MpdStatus?,
): MediaMetadataCompat =
MediaMetadataCompat.Builder()
.apply {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, song?.title ?: song?.uri ?: "")
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song?.artist ?: "")
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()
.build()
private fun buildPlaybackState(status: MpdStatus?): PlaybackStateCompat {
val state = when (status?.state) {
PlayerState.PLAY -> PlaybackStateCompat.STATE_PLAYING
PlayerState.PAUSE -> PlaybackStateCompat.STATE_PAUSED
else -> PlaybackStateCompat.STATE_STOPPED
}
val state =
when (status?.state) {
PlayerState.PLAY -> PlaybackStateCompat.STATE_PLAYING
PlayerState.PAUSE -> PlaybackStateCompat.STATE_PAUSED
else -> PlaybackStateCompat.STATE_STOPPED
}
val positionMs = ((status?.elapsed ?: 0.0) * 1000).toLong()
val speed = if (status?.state == PlayerState.PLAY) 1f else 0f
return PlaybackStateCompat.Builder()
@@ -185,7 +205,7 @@ class PlaybackService : Service() {
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_STOP,
PlaybackStateCompat.ACTION_STOP
)
.setState(state, positionMs, speed)
.build()
@@ -195,26 +215,34 @@ class PlaybackService : Service() {
val song = manager.currentSong.value
val playing = manager.status.value?.state == PlayerState.PLAY
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val contentIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val playPause = if (playing) {
NotificationCompat.Action(
android.R.drawable.ic_media_pause,
"Pause",
MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE),
)
} else {
NotificationCompat.Action(
android.R.drawable.ic_media_play,
"Play",
MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE),
)
}
val playPause =
if (playing) {
NotificationCompat.Action(
android.R.drawable.ic_media_pause,
"Pause",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_PLAY_PAUSE,
),
)
} else {
NotificationCompat.Action(
android.R.drawable.ic_media_play,
"Play",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_PLAY_PAUSE,
),
)
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_music_note)
@@ -227,18 +255,24 @@ class PlaybackService : Service() {
.addAction(
android.R.drawable.ic_media_previous,
"Previous",
MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS),
MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS,
),
)
.addAction(playPause)
.addAction(
android.R.drawable.ic_media_next,
"Next",
MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT),
MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT,
),
)
.setStyle(
MediaStyle()
.setMediaSession(session.sessionToken)
.setShowActionsInCompactView(0, 1, 2),
.setShowActionsInCompactView(0, 1, 2)
)
.build()
}
@@ -250,14 +284,16 @@ class PlaybackService : Service() {
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Playback",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Now-playing controls"
setShowBadge(false)
}
val channel =
NotificationChannel(
CHANNEL_ID,
"Playback",
NotificationManager.IMPORTANCE_LOW,
)
.apply {
description = "Now-playing controls"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
@@ -5,32 +5,30 @@ import android.view.KeyEvent
/**
* Encapsulates hardware volume-key handling for the foreground Activity.
*
* While the app is focused, we want the volume keys to drive the *server*
* volume **silently** — without the system's volume slider popping up (the app
* already shows its own). The trick is to fully consume the key event in the
* Activity so it never reaches the OS volume handling that draws that slider.
* While the app is focused, we want the volume keys to drive the *server* volume **silently** —
* without the system's volume slider popping up (the app already shows its own). The trick is to
* fully consume the key event in the Activity so it never reaches the OS volume handling that draws
* that slider.
*
* When the app is backgrounded the Activity isn't in the dispatch path at all,
* so the `MediaSession`'s `VolumeProvider` handles the keys instead — there the
* OS remote-volume UI showing up is the expected, cast-style behaviour.
* When the app is backgrounded the Activity isn't in the dispatch path at all, so the
* `MediaSession`'s `VolumeProvider` handles the keys instead — there the OS remote-volume UI
* showing up is the expected, cast-style behaviour.
*
* Only volume keys we actually act on are consumed: when we're not controlling
* the server (e.g. the connect screen), the event passes through so the device
* adjusts its own local volume normally.
* Only volume keys we actually act on are consumed: when we're not controlling the server (e.g. the
* connect screen), the event passes through so the device adjusts its own local volume normally.
*/
class VolumeKeyDispatcher(private val manager: MpdConnectionManager) {
/**
* Offer [event] to the volume handler. Returns `true` if it was a volume key
* we handled and consumed (caller should then *not* pass it on), `false` to
* let normal dispatch continue.
* Offer [event] to the volume handler. Returns `true` if it was a volume key we handled and
* consumed (caller should then *not* pass it on), `false` to let normal dispatch continue.
*/
fun dispatch(event: KeyEvent): Boolean {
val up = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> true
KeyEvent.KEYCODE_VOLUME_DOWN -> false
else -> return false
}
val up =
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> true
KeyEvent.KEYCODE_VOLUME_DOWN -> false
else -> return false
}
if (!manager.isControllingVolume) return false
// Nudge on each key-down (auto-repeats included, so holding keeps
@@ -46,10 +46,10 @@ import ca.ksamad.encore.playback.AlbumArt
import kotlinx.coroutines.launch
/**
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and
* the track list. Tapping Play replaces the queue and starts the album (leaving
* this screen); swiping a track queues it (right) or plays it next (left), with
* the same semantics as swiping an album in the library.
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
* Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track
* queues it (right) or plays it next (left), with the same semantics as swiping an album in the
* library.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -60,12 +60,14 @@ fun AlbumDetailScreen(
onPlay: () -> Unit,
) {
// null = still loading the track list.
val tracks by produceState<List<MpdSong>?>(initialValue = null, album) {
value = vm.loadAlbumTracks(album.name, album.albumArtist)
}
val tracks by
produceState<List<MpdSong>?>(initialValue = null, album) {
value = vm.loadAlbumTracks(album.name, album.albumArtist)
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun flash(message: String) {
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch { snackbarHostState.showSnackbar(message) }
@@ -117,34 +119,43 @@ fun AlbumDetailScreen(
val loaded = tracks
when {
loaded == null -> item {
Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
loaded == null -> {
item {
Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
loaded.isEmpty() -> item {
Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No tracks found",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
loaded.isEmpty() -> {
item {
Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
"No tracks found",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Single disc (or untagged): a plain flat list.
loaded.map(::discNumberOf).distinct().size <= 1 ->
loaded.map(::discNumberOf).distinct().size <= 1 -> {
items(loaded, key = { it.uri }) { trackItem(it) }
}
// Multi-disc: a light "Disc N" header before each disc's tracks. The
// list arrives already sorted by (disc, track), so groupBy keeps order.
else -> loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) ->
item(key = "disc-$disc") { DiscHeader(disc) }
items(discTracks, key = { it.uri }) { trackItem(it) }
else -> {
loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) ->
item(key = "disc-$disc") { DiscHeader(disc) }
items(discTracks, key = { it.uri }) { trackItem(it) }
}
}
}
}
@@ -201,10 +212,9 @@ private fun AlbumDetailHeader(
}
/**
* A single track row. Swiping right queues the track, swiping left plays it next
* — the same gesture as the album library, scoped to this one track. The leading
* slot shows the track number (all tracks share the album's cover, so a per-track
* thumbnail would be redundant here).
* A single track row. Swiping right queues the track, swiping left plays it next — the same gesture
* as the album library, scoped to this one track. The leading slot shows the track number (all
* tracks share the album's cover, so a per-track thumbnail would be redundant here).
*/
@Composable
private fun TrackRow(
@@ -226,9 +236,10 @@ private fun TrackRow(
headlineContent = {
Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = track.artist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
supportingContent =
track.artist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
trailingContent = track.duration?.let { { Text(formatDuration(it)) } },
)
}
@@ -52,18 +52,25 @@ import ca.ksamad.encore.playback.AlbumArt
import kotlinx.coroutines.launch
/**
* Browse every album on the server. Tapping one opens its detail screen; swiping
* a row queues the whole album (right) or plays it next (left).
* Browse every album on the server. Tapping one opens its detail screen; swiping a row queues the
* whole album (right) or plays it next (left).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onOpenAlbum: (MpdAlbum) -> Unit) {
fun AlbumsScreen(
vm: PlayerViewModel,
onBack: () -> Unit,
onOpenAlbum: (MpdAlbum) -> Unit,
) {
// null = still loading.
val albums by produceState<List<MpdAlbum>?>(initialValue = null) {
value = vm.loadAlbums().sortedWith(
compareBy({ it.albumArtist?.lowercase() ?: "" }, { it.name.lowercase() }),
)
}
val albums by
produceState<List<MpdAlbum>?>(initialValue = null) {
value =
vm.loadAlbums()
.sortedWith(
compareBy({ it.albumArtist?.lowercase() ?: "" }, { it.name.lowercase() })
)
}
// Client-side search over the already-loaded index (album title + album artist).
var searching by remember { mutableStateOf(false) }
@@ -72,6 +79,7 @@ fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onOpenAlbum: (MpdAlbum
// Transient confirmation for the swipe actions (add-to-queue / play-next).
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun flash(message: String) {
// Replace any in-flight snackbar so rapid swipes feel snappy, not queued.
snackbarHostState.currentSnackbarData?.dismiss()
@@ -79,20 +87,29 @@ fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onOpenAlbum: (MpdAlbum
}
val current = albums
val filtered = remember(current, query) {
// Match every whitespace-separated term against the album title + artist
// together, so a query can span both fields ("beatles abbey") and word
// order doesn't matter.
val terms = query.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
when {
current == null -> null
terms.isEmpty() -> current
else -> current.filter { album ->
val haystack = "${album.name} ${album.albumArtist ?: ""}".lowercase()
terms.all { haystack.contains(it) }
val filtered =
remember(current, query) {
// Match every whitespace-separated term against the album title + artist
// together, so a query can span both fields ("beatles abbey") and word
// order doesn't matter.
val terms = query.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
when {
current == null -> {
null
}
terms.isEmpty() -> {
current
}
else -> {
current.filter { album ->
val haystack = "${album.name} ${album.albumArtist ?: ""}".lowercase()
terms.all { haystack.contains(it) }
}
}
}
}
}
Scaffold(
topBar = {
@@ -105,14 +122,16 @@ fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onOpenAlbum: (MpdAlbum
}
},
navigationIcon = {
IconButton(onClick = {
if (searching) {
searching = false
query = ""
} else {
onBack()
IconButton(
onClick = {
if (searching) {
searching = false
query = ""
} else {
onBack()
}
}
}) {
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
@@ -132,45 +151,54 @@ fun AlbumsScreen(vm: PlayerViewModel, onBack: () -> Unit, onOpenAlbum: (MpdAlbum
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
when {
filtered == null -> Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
filtered.isEmpty() -> Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
Text(
if (query.isBlank()) "No albums found" else "No albums match \"${query.trim()}\"",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
filtered == null -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
else -> LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
SwipeableAlbumRow(
album = album,
onTap = { onOpenAlbum(album) },
onQueue = {
vm.queueAlbum(album.name, album.albumArtist)
flash("Added “${album.name}” to the queue")
},
onPlayNext = {
vm.playAlbumNext(album.name, album.albumArtist)
flash("${album.name}” will play next")
},
filtered.isEmpty() -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
Text(
if (query.isBlank()) "No albums found"
else "No albums match \"${query.trim()}\"",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
SwipeableAlbumRow(
album = album,
onTap = { onOpenAlbum(album) },
onQueue = {
vm.queueAlbum(album.name, album.albumArtist)
flash("Added “${album.name}” to the queue")
},
onPlayNext = {
vm.playAlbumNext(album.name, album.albumArtist)
flash("${album.name}” will play next")
},
)
}
}
}
}
}
}
/**
* One album row. Tapping opens the album ([onTap]); swiping reveals two one-shot
* actions that leave the row in place — right ([onQueue]) appends the album to the
* queue, left ([onPlayNext]) inserts it right after the current track.
* One album row. Tapping opens the album ([onTap]); swiping reveals two one-shot actions that leave
* the row in place — right ([onQueue]) appends the album to the queue, left ([onPlayNext]) inserts
* it right after the current track.
*/
@Composable
private fun SwipeableAlbumRow(
@@ -186,28 +214,30 @@ private fun SwipeableAlbumRow(
ArtImage(
model = AlbumArt(album.name, album.albumArtist),
iconSize = 24.dp,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(6.dp)),
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp)),
)
},
headlineContent = {
Text(album.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = album.albumArtist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
supportingContent =
album.albumArtist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
)
}
}
/**
* Inline search box that lives in the top bar while searching. Auto-focuses and
* opens the keyboard; the surrounding [TopAppBar] handles clearing/closing.
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
* the surrounding [TopAppBar] handles clearing/closing.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlbumSearchField(query: String, onQueryChange: (String) -> Unit) {
private fun AlbumSearchField(
query: String,
onQueryChange: (String) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
@@ -220,12 +250,13 @@ private fun AlbumSearchField(query: String, onQueryChange: (String) -> Unit) {
placeholder = { Text("Search albums & artists") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
@@ -16,13 +16,17 @@ 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.encore.playback.MpdArtData] (or null to show just the placeholder).
* 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.encore.playback.MpdArtData] (or null
* to show just the placeholder).
*/
@Composable
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
fun ArtImage(
model: Any?,
iconSize: Dp,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
@@ -29,18 +29,25 @@ import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.mpd.model.MpdAlbum
/**
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
* brief loading splash while it reads settings / auto-connects, the connect form
* (first run, after disconnect, or on error), or the now-playing screen.
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a brief loading splash
* while it reads settings / auto-connects, the connect form (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, Queue }
private enum class PlayerOverlay {
None,
Settings,
Albums,
Queue,
}
/** Saves the drilled-into album (name + artist) across config change/process death. */
private val AlbumSaver = listSaver<MpdAlbum?, String?>(
save = { listOf(it?.name, it?.albumArtist) },
restore = { saved -> saved[0]?.let { name -> MpdAlbum(name, saved[1]) } },
)
private val AlbumSaver =
listSaver<MpdAlbum?, String?>(
save = { listOf(it?.name, it?.albumArtist) },
restore = { saved -> saved[0]?.let { name -> MpdAlbum(name, saved[1]) } },
)
@Composable
fun EncoreApp(vm: PlayerViewModel = viewModel()) {
@@ -59,37 +66,54 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
}
when (val s = screen) {
is AppScreen.Loading -> LoadingScreen()
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
is AppScreen.Player -> when (overlay) {
PlayerOverlay.Settings -> SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
PlayerOverlay.Albums -> {
val selected = detailAlbum
if (selected != null) {
AlbumDetailScreen(
is AppScreen.Loading -> {
LoadingScreen()
}
is AppScreen.Connect -> {
ConnectScreen(vm, s.settings, s.error)
}
is AppScreen.Player -> {
when (overlay) {
PlayerOverlay.Settings -> {
SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
}
PlayerOverlay.Albums -> {
val selected = detailAlbum
if (selected != null) {
AlbumDetailScreen(
vm,
album = selected,
onBack = { detailAlbum = null },
onPlay = {
detailAlbum = null
overlay = PlayerOverlay.None
},
)
} else {
AlbumsScreen(
vm,
onBack = { overlay = PlayerOverlay.None },
onOpenAlbum = { detailAlbum = it },
)
}
}
PlayerOverlay.Queue -> {
QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
}
PlayerOverlay.None -> {
NowPlayingScreen(
vm,
album = selected,
onBack = { detailAlbum = null },
onPlay = {
detailAlbum = null
overlay = PlayerOverlay.None
},
)
} else {
AlbumsScreen(
vm,
onBack = { overlay = PlayerOverlay.None },
onOpenAlbum = { detailAlbum = it },
onOpenSettings = { overlay = PlayerOverlay.Settings },
onOpenLibrary = { overlay = PlayerOverlay.Albums },
onOpenQueue = { overlay = PlayerOverlay.Queue },
)
}
}
PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
PlayerOverlay.None -> NowPlayingScreen(
vm,
onOpenSettings = { overlay = PlayerOverlay.Settings },
onOpenLibrary = { overlay = PlayerOverlay.Albums },
onOpenQueue = { overlay = PlayerOverlay.Queue },
)
}
}
}
@@ -111,7 +135,11 @@ private fun LoadingScreen() {
}
@Composable
private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error: String?) {
private fun ConnectScreen(
vm: PlayerViewModel,
saved: ConnectionSettings,
error: String?,
) {
// Seed the fields from the persisted settings. Keyed on the loaded values so
// the form re-seeds once DataStore delivers them, but a user's edits after
// that stick (rememberSaveable also survives rotation/process death).
@@ -119,9 +147,7 @@ private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error:
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -145,9 +171,10 @@ private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error:
onValueChange = { port = it.filter(Char::isDigit) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Number,
),
keyboardOptions =
androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Number
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
@@ -1,8 +1,6 @@
package ca.ksamad.encore.ui
import androidx.compose.foundation.background
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.ExperimentalLayoutApi
@@ -14,7 +12,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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.Cast
@@ -22,8 +22,8 @@ import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.VolumeUp
@@ -50,15 +50,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.encore.playback.SongArt
import ca.ksamad.encore.mpd.model.PlayerState
import ca.ksamad.encore.playback.SongArt
import kotlinx.coroutines.delay
/**
* The now-playing screen: current track, a live seek bar, transport controls,
* volume, and the repeat/random toggles. Everything reads from the
* [PlayerViewModel] flows, so it updates whenever the server pushes a change —
* including changes made from other clients.
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
* the server pushes a change — including changes made from other clients.
*/
@Composable
fun NowPlayingScreen(
@@ -72,10 +71,7 @@ fun NowPlayingScreen(
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// --- Top bar: library + settings ------------------------------------
@@ -95,10 +91,8 @@ fun NowPlayingScreen(
ArtImage(
model = song?.uri?.let { SongArt(it) },
iconSize = 96.dp,
modifier = Modifier
.padding(vertical = 16.dp)
.size(240.dp)
.clip(RoundedCornerShape(16.dp)),
modifier =
Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)),
)
// --- Track metadata --------------------------------------------------
@@ -126,10 +120,11 @@ fun NowPlayingScreen(
)
// Descriptive metadata (release year · genre), when the tags are present.
val descriptors = listOfNotNull(
song?.date?.let(::releaseYear),
song?.genre?.takeIf { it.isNotBlank() },
)
val descriptors =
listOfNotNull(
song?.date?.let(::releaseYear),
song?.genre?.takeIf { it.isNotBlank() },
)
if (descriptors.isNotEmpty()) {
Text(
text = descriptors.joinToString(" · "),
@@ -222,20 +217,23 @@ fun NowPlayingScreen(
}
/**
* Seek bar that ticks locally between server updates. MPD only pushes a change
* event on discrete events (play/pause/seek/song change), not once per second,
* so we advance [MpdStatus.elapsed] locally while playing to keep the bar
* moving, resyncing whenever a fresh status arrives.
* Seek bar that ticks locally between server updates. MPD only pushes a change event on discrete
* events (play/pause/seek/song change), not once per second, so we advance [MpdStatus.elapsed]
* locally while playing to keep the bar moving, resyncing whenever a fresh status arrives.
*/
@Composable
private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) {
private fun SeekBar(
status: MpdStatus?,
onSeek: (Double) -> Unit,
) {
val duration = status?.duration ?: 0.0
val playing = status?.state == PlayerState.PLAY
// Local playback position, reseeded on every new status snapshot.
var position by remember(status?.songId, status?.elapsed, status?.state) {
mutableFloatStateOf((status?.elapsed ?: 0.0).toFloat())
}
var position by
remember(status?.songId, status?.elapsed, status?.state) {
mutableFloatStateOf((status?.elapsed ?: 0.0).toFloat())
}
var dragValue by remember { mutableStateOf<Float?>(null) }
// Advance ~4x/sec while playing and not being dragged.
@@ -266,9 +264,9 @@ private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) {
}
/**
* Little pills describing the current stream's audio format — sample rate, bit
* depth, channels, and (live) bitrate — derived from MPD's `audio`/`bitrate`
* status fields. Renders nothing when there's no format info (e.g. stopped).
* Little pills describing the current stream's audio format — sample rate, bit depth, channels, and
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
* no format info (e.g. stopped).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -277,9 +275,7 @@ private fun AudioPropertyPills(status: MpdStatus?) {
if (pills.isEmpty()) return
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -288,29 +284,32 @@ private fun AudioPropertyPills(status: MpdStatus?) {
text = pill,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 10.dp, vertical = 4.dp),
modifier =
Modifier.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
}
/**
* Build the audio-format pill labels from a status snapshot. MPD's `audio` field
* is `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can
* be `f` (float) or `dsd` rather than a bit depth. Only well-formed parts appear.
* Build the audio-format pill labels from a status snapshot. MPD's `audio` field is
* `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can be `f` (float) or
* `dsd` rather than a bit depth. Only well-formed parts appear.
*/
private fun audioPropertyPills(status: MpdStatus?): List<String> {
status ?: return emptyList()
val pills = mutableListOf<String>()
status.audio?.split(":")?.takeIf { it.size >= 3 }?.let { parts ->
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
formatSampleFormat(parts[1])?.let { pills.add(it) }
formatChannels(parts[2])?.let { pills.add(it) }
}
status.audio
?.split(":")
?.takeIf { it.size >= 3 }
?.let { parts ->
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
formatSampleFormat(parts[1])?.let { pills.add(it) }
formatChannels(parts[2])?.let { pills.add(it) }
}
status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") }
return pills
@@ -322,27 +321,27 @@ private fun formatSampleRate(hz: Int): String {
return "$value kHz"
}
private fun formatSampleFormat(token: String): String? = when (token) {
"f" -> "Float"
"dsd" -> "DSD"
else -> token.toIntOrNull()?.let { "$it-bit" }
}
private fun formatSampleFormat(token: String): String? =
when (token) {
"f" -> "Float"
"dsd" -> "DSD"
else -> token.toIntOrNull()?.let { "$it-bit" }
}
private fun formatChannels(token: String): String? = when (token.toIntOrNull()) {
null -> null
1 -> "Mono"
2 -> "Stereo"
else -> "$token ch"
}
private fun formatChannels(token: String): String? =
when (token.toIntOrNull()) {
null -> null
1 -> "Mono"
2 -> "Stereo"
else -> "$token ch"
}
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
@Composable
private fun CastIndicator(host: String?) {
if (host == null) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
@@ -362,7 +361,10 @@ private fun CastIndicator(host: String?) {
}
@Composable
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
private fun VolumeControl(
volume: Int?,
onSetVolume: (Int) -> Unit,
) {
// Local thumb position seeded from the server; committed on release.
var dragValue by remember(volume) { mutableStateOf<Float?>(null) }
val enabled = volume != null
@@ -383,9 +385,7 @@ private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
},
valueRange = 0f..100f,
enabled = enabled,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
)
Text(
text = if (enabled) "${shown.toInt()}" else "",
@@ -397,8 +397,7 @@ private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
}
/** The 4-digit year from an MPD `Date` tag (`"2019"`, `"2019-05-03"`, …), or null. */
private fun releaseYear(date: String): String? =
Regex("\\d{4}").find(date)?.value
private fun releaseYear(date: String): String? = Regex("\\d{4}").find(date)?.value
private fun formatTime(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0)
@@ -17,21 +17,22 @@ sealed interface AppScreen {
data object Loading : AppScreen
/** First run, after a disconnect, or a failed connection. */
data class Connect(val settings: ConnectionSettings, val error: String?) : AppScreen
data class Connect(
val settings: ConnectionSettings,
val error: String?,
) : AppScreen
/** Connected: show playback. */
data object Player : AppScreen
}
/**
* Thin UI-facing layer over the app-scoped
* [ca.ksamad.encore.playback.MpdConnectionManager]: it exposes the manager's
* flows to Compose and derives the top-level [AppScreen]. The connection itself
* lives in the manager (shared with the foreground service), so it survives this
* ViewModel being cleared on Activity recreation.
* Thin UI-facing layer over the app-scoped [ca.ksamad.encore.playback.MpdConnectionManager]: it
* exposes the manager's flows to Compose and derives the top-level [AppScreen]. The connection
* itself lives in the manager (shared with the foreground service), so it survives this ViewModel
* being cleared on Activity recreation.
*/
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
private val manager = (application as EncoreApplication).manager
val status = manager.status
@@ -39,49 +40,90 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val serverHost = manager.serverHost
val settings = manager.settings
val screen: StateFlow<AppScreen> = combine(
manager.connectionState,
manager.bootstrapping,
manager.settings,
) { connection, booting, saved ->
when {
booting -> AppScreen.Loading
connection is MpdConnectionState.Connected -> AppScreen.Player
connection is MpdConnectionState.Connecting -> AppScreen.Loading
connection is MpdConnectionState.Error -> AppScreen.Connect(saved, connection.message)
else -> AppScreen.Connect(saved, null) // Disconnected after startup
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppScreen.Loading,
)
val screen: StateFlow<AppScreen> =
combine(
manager.connectionState,
manager.bootstrapping,
manager.settings,
) { connection, booting, saved ->
when {
booting -> AppScreen.Loading
connection is MpdConnectionState.Connected -> AppScreen.Player
connection is MpdConnectionState.Connecting -> AppScreen.Loading
connection is MpdConnectionState.Error ->
AppScreen.Connect(saved, connection.message)
else -> AppScreen.Connect(saved, null) // Disconnected after startup
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppScreen.Loading,
)
fun connect(
host: String,
port: Int,
) = manager.connect(host, port)
fun connect(host: String, port: Int) = manager.connect(host, port)
fun disconnect() = manager.disconnect()
fun resetSettings() = manager.resetSettings()
fun togglePlayPause() = manager.togglePlayPause()
fun next() = manager.next()
fun previous() = manager.previous()
fun seekTo(seconds: Double) = manager.seekTo(seconds)
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)
fun queueAlbum(album: String, albumArtist: String?) = manager.queueAlbum(album, albumArtist)
fun playAlbumNext(album: String, albumArtist: String?) = manager.playAlbumNext(album, albumArtist)
fun playAlbum(
album: String,
albumArtist: String?,
) = manager.playAlbum(album, albumArtist)
fun queueAlbum(
album: String,
albumArtist: String?,
) = manager.queueAlbum(album, albumArtist)
fun playAlbumNext(
album: String,
albumArtist: String?,
) = manager.playAlbumNext(album, albumArtist)
fun queueTrack(uri: String) = manager.queueTrack(uri)
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
suspend fun loadAlbumTracks(album: String, albumArtist: String?) =
manager.loadAlbumTracks(album, albumArtist)
suspend fun loadAlbumTracks(
album: String,
albumArtist: String?,
) = manager.loadAlbumTracks(album, albumArtist)
suspend fun loadAlbums() = manager.loadAlbums()
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
fun addTrackAt(uri: String, position: Int) = manager.addTrackAt(uri, position)
fun addTrackAt(
uri: String,
position: Int,
) = manager.addTrackAt(uri, position)
fun clearQueue() = manager.clearQueue()
suspend fun loadStatistics() = manager.loadStatistics()
suspend fun loadQueue() = manager.loadQueue()
}
@@ -54,24 +54,27 @@ import ca.ksamad.encore.playback.SongArt
import kotlinx.coroutines.launch
/**
* "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.
* "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.
* 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) {
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()
}
val fullQueue by
produceState<List<MpdSong>?>(initialValue = null, status?.playlistVersion) {
value = vm.loadQueue()
}
// Ids swiped away locally, hidden immediately so the row leaves without waiting
// for the server round-trip. Reset on every reload — by then the fresh queue
@@ -81,7 +84,8 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
// 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 ->
val slice = if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q
val slice =
if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q
if (pendingRemoval.isEmpty()) slice else slice.filter { it.id !in pendingRemoval }
}
val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true)
@@ -93,17 +97,19 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
// Undo affordance for swipe-to-remove.
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun removeSong(song: MpdSong) {
val id = song.id ?: return
pendingRemoval = pendingRemoval + id
vm.removeQueueItem(id)
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Removed “${song.title ?: song.uri}",
actionLabel = "Undo",
duration = SnackbarDuration.Long,
)
val result =
snackbarHostState.showSnackbar(
message = "Removed “${song.title ?: song.uri}",
actionLabel = "Undo",
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) {
// Re-insert at its original index; fall back to append if unknown.
song.pos?.let { vm.addTrackAt(song.uri, it) } ?: vm.queueTrack(song.uri)
@@ -133,40 +139,58 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
snackbarHost = { SnackbarHost(snackbarHostState) },
) { 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),
)
}
upcoming == null -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
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()
val id = song.id
// The current track and id-less entries aren't swipe-removable —
// removing the current one would disrupt playback.
if (isCurrent || id == null) {
QueueRow(song, isCurrent, onClick = { id?.let { vm.playQueueItem(it) } })
} else {
SwipeToRemoveRow(onRemove = { removeSong(song) }) {
QueueRow(song, isCurrent = false, onClick = { vm.playQueueItem(id) })
}
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()
val id = song.id
// The current track and id-less entries aren't swipe-removable —
// removing the current one would disrupt playback.
if (isCurrent || id == null) {
QueueRow(
song,
isCurrent,
onClick = { id?.let { vm.playQueueItem(it) } },
)
} else {
SwipeToRemoveRow(onRemove = { removeSong(song) }) {
QueueRow(
song,
isCurrent = false,
onClick = { vm.playQueueItem(id) },
)
}
}
}
}
@@ -180,10 +204,14 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
title = { Text("Clear the queue?") },
text = { Text("This removes every track from the queue and stops playback.") },
confirmButton = {
TextButton(onClick = {
vm.clearQueue()
confirmClear = false
}) { Text("Clear") }
TextButton(
onClick = {
vm.clearQueue()
confirmClear = false
}
) {
Text("Clear")
}
},
dismissButton = {
TextButton(onClick = { confirmClear = false }) { Text("Cancel") }
@@ -194,14 +222,19 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
@Composable
private fun QueueRow(song: MpdSong, isCurrent: Boolean, onClick: () -> Unit) {
private fun QueueRow(
song: MpdSong,
isCurrent: Boolean,
onClick: () -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
colors = if (isCurrent) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
colors =
if (isCurrent) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
leadingContent = {
ArtImage(
model = SongArt(song.uri),
@@ -217,35 +250,41 @@ private fun QueueRow(song: MpdSong, isCurrent: Boolean, onClick: () -> Unit) {
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)) } }
},
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)) } }
},
)
}
/**
* Wraps [content] in a swipe-to-remove gesture: a swipe in either direction
* settles the row off-screen and calls [onRemove]. The reveal is a red trash
* background on whichever edge is being swiped from.
* Wraps [content] in a swipe-to-remove gesture: a swipe in either direction settles the row
* off-screen and calls [onRemove]. The reveal is a red trash background on whichever edge is being
* swiped from.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwipeToRemoveRow(onRemove: () -> Unit, content: @Composable () -> Unit) {
val state = rememberSwipeToDismissBoxState(
confirmValueChange = { target ->
if (target != SwipeToDismissBoxValue.Settled) {
onRemove()
true // Commit the dismiss; the row is also filtered from the list.
} else {
false
private fun SwipeToRemoveRow(
onRemove: () -> Unit,
content: @Composable () -> Unit,
) {
val state =
rememberSwipeToDismissBoxState(
confirmValueChange = { target ->
if (target != SwipeToDismissBoxValue.Settled) {
onRemove()
true // Commit the dismiss; the row is also filtered from the list.
} else {
false
}
}
},
)
)
SwipeToDismissBox(
state = state,
backgroundContent = { RemoveBackground(state.dismissDirection) },
@@ -262,14 +301,14 @@ private fun RemoveBackground(direction: SwipeToDismissBoxValue) {
Box(Modifier.fillMaxSize())
return
}
val alignment = if (direction == SwipeToDismissBoxValue.StartToEnd) {
Alignment.CenterStart
} else {
Alignment.CenterEnd
}
val alignment =
if (direction == SwipeToDismissBoxValue.StartToEnd) {
Alignment.CenterStart
} else {
Alignment.CenterEnd
}
Box(
Modifier
.fillMaxSize()
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 24.dp),
contentAlignment = alignment,
@@ -283,13 +322,14 @@ private fun RemoveBackground(direction: SwipeToDismissBoxValue) {
}
/** 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 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
}
/** `m:ss` for a duration in seconds. Shared across the queue/album track lists. */
internal fun formatDuration(seconds: Double): String {
@@ -42,12 +42,15 @@ import java.util.Date
import java.util.Locale
/**
* Settings: shows the current server and offers to switch servers or reset all
* saved settings. Reached from the player via the gear icon; [onBack] pops back.
* Settings: shows the current server and offers to switch servers or reset all saved settings.
* Reached from the player via the gear icon; [onBack] pops back.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
fun SettingsScreen(
vm: PlayerViewModel,
onBack: () -> Unit,
) {
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var confirmReset by remember { mutableStateOf(false) }
@@ -55,10 +58,11 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
// Server statistics (`stats`), fetched once when the screen opens. `null`
// means either still loading or unavailable — [statsLoaded] disambiguates.
var statsLoaded by remember { mutableStateOf(false) }
val stats by produceState<MpdStatistics?>(initialValue = null) {
value = vm.loadStatistics()
statsLoaded = true
}
val stats by
produceState<MpdStatistics?>(initialValue = null) {
value = vm.loadStatistics()
statsLoaded = true
}
Scaffold(
topBar = {
@@ -70,14 +74,14 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
}
},
)
},
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 8.dp),
modifier =
Modifier.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 8.dp)
) {
Text(
"Server",
@@ -122,49 +126,55 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp),
)
when {
!statsLoaded -> ListItem(
headlineContent = { Text("Loading…") },
trailingContent = {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
},
)
!statsLoaded -> {
ListItem(
headlineContent = { Text("Loading…") },
trailingContent = {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
},
)
}
stats == null -> ListItem(
headlineContent = { Text("Statistics unavailable") },
supportingContent = { Text("The server didn't report any statistics") },
)
stats == null -> {
ListItem(
headlineContent = { Text("Statistics unavailable") },
supportingContent = { Text("The server didn't report any statistics") },
)
}
else -> stats?.let { s ->
StatRow("Artists", formatCount(s.artists))
StatRow("Albums", formatCount(s.albums))
StatRow("Tracks", formatCount(s.songs))
StatRow("Library length", formatStatDuration(s.dbPlaytimeSeconds))
StatRow("Time played", formatStatDuration(s.playtimeSeconds))
StatRow("Server uptime", formatStatDuration(s.uptimeSeconds))
StatRow("Last updated", formatTimestamp(s.dbUpdateEpochSeconds))
else -> {
stats?.let { s ->
StatRow("Artists", formatCount(s.artists))
StatRow("Albums", formatCount(s.albums))
StatRow("Tracks", formatCount(s.songs))
StatRow("Library length", formatStatDuration(s.dbPlaytimeSeconds))
StatRow("Time played", formatStatDuration(s.playtimeSeconds))
StatRow("Server uptime", formatStatDuration(s.uptimeSeconds))
StatRow("Last updated", formatTimestamp(s.dbUpdateEpochSeconds))
}
}
}
Spacer(Modifier.height(24.dp))
OutlinedButton(
onClick = { onBack(); vm.disconnect() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
onClick = {
onBack()
vm.disconnect()
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
) {
Text("Change server")
}
Spacer(Modifier.height(8.dp))
Button(
onClick = { confirmReset = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
) {
Text("Reset settings")
}
@@ -177,11 +187,15 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
title = { Text("Reset settings?") },
text = { Text("This forgets the saved server and disconnects.") },
confirmButton = {
TextButton(onClick = {
confirmReset = false
onBack()
vm.resetSettings()
}) { Text("Reset") }
TextButton(
onClick = {
confirmReset = false
onBack()
vm.resetSettings()
}
) {
Text("Reset")
}
},
dismissButton = {
TextButton(onClick = { confirmReset = false }) { Text("Cancel") }
@@ -192,7 +206,10 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
/** A label/value line matching the Host/Port rows above. */
@Composable
private fun StatRow(label: String, value: String) {
private fun StatRow(
label: String,
value: String,
) {
ListItem(
headlineContent = { Text(label) },
trailingContent = { Text(value) },
@@ -203,8 +220,8 @@ private fun StatRow(label: String, value: String) {
private fun formatCount(n: Int): String = "%,d".format(n)
/**
* A duration in seconds as a compact `Xd Yh Zm`, dropping leading zero units but
* always showing at least minutes. Non-positive values render as an em dash.
* A duration in seconds as a compact `Xd Yh Zm`, dropping leading zero units but always showing at
* least minutes. Non-positive values render as an em dash.
*/
private fun formatStatDuration(totalSeconds: Long): String {
if (totalSeconds <= 0) return ""
@@ -23,14 +23,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to
* [onAddToQueue] (append), swipe left to [onPlayNext] (insert after the current
* track). Used for both album rows and individual tracks so the gesture and its
* meaning stay identical wherever it appears.
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
*
* Both are one-shot actions, not deletions, so the row always springs back and
* stays in the list — [content] should be opaque (e.g. a `ListItem`) so it hides
* the coloured reveal once settled.
* Both are one-shot actions, not deletions, so the row always springs back and stays in the list —
* [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -39,16 +37,23 @@ fun QueueSwipeRow(
onPlayNext: () -> Unit,
content: @Composable () -> Unit,
) {
val state = rememberSwipeToDismissBoxState(
confirmValueChange = { target ->
when (target) {
SwipeToDismissBoxValue.StartToEnd -> onAddToQueue()
SwipeToDismissBoxValue.EndToStart -> onPlayNext()
SwipeToDismissBoxValue.Settled -> {}
val state =
rememberSwipeToDismissBoxState(
confirmValueChange = { target ->
when (target) {
SwipeToDismissBoxValue.StartToEnd -> {
onAddToQueue()
}
SwipeToDismissBoxValue.EndToStart -> {
onPlayNext()
}
SwipeToDismissBoxValue.Settled -> {}
}
false // Never settle to dismissed — snap back and keep the row.
}
false // Never settle to dismissed — snap back and keep the row.
},
)
)
SwipeToDismissBox(
state = state,
@@ -59,9 +64,9 @@ fun QueueSwipeRow(
}
/**
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the
* leading edge (swipe right) and a "Play next" hint on the trailing edge (swipe
* left). Renders empty while the row is settled.
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (swipe
* right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is
* settled.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -72,24 +77,23 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
}
val queueing = direction == SwipeToDismissBoxValue.StartToEnd
val container = if (queueing) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.tertiaryContainer
}
val onContainer = if (queueing) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onTertiaryContainer
}
val container =
if (queueing) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.tertiaryContainer
}
val onContainer =
if (queueing) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onTertiaryContainer
}
val icon = if (queueing) Icons.Filled.Add else Icons.Filled.PlayArrow
val label = if (queueing) "Add to queue" else "Play next"
Box(
Modifier
.fillMaxSize()
.background(container)
.padding(horizontal = 24.dp),
Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp),
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -4,9 +4,8 @@ import org.junit.Assert.assertEquals
import org.junit.Test
/**
* A plain JVM unit test (runs on your machine, no device/emulator needed).
* `gradle testDebugUnitTest` runs these — that's what the Nix build's check
* phase invokes.
* A plain JVM unit test (runs on your machine, no device/emulator needed). `gradle
* testDebugUnitTest` runs these — that's what the Nix build's check phase invokes.
*/
class ExampleUnitTest {
@Test
@@ -8,16 +8,15 @@ import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* Live end-to-end test of [MpdClient] against a real server (skipped unless
* `MPD_HOST` is set — see [MpdServerIntegrationTest]).
* Live end-to-end test of [MpdClient] against a real server (skipped unless `MPD_HOST` is set — see
* [MpdServerIntegrationTest]).
*
* The interesting part is the idle round-trip: we change the volume on the
* **command** connection and then wait for that change to arrive back through
* the **idle** connection into the `status` flow — proving the two-connection
* push architecture actually works. The volume nudge is reverted afterwards.
* The interesting part is the idle round-trip: we change the volume on the **command** connection
* and then wait for that change to arrive back through the **idle** connection into the `status`
* flow — proving the two-connection push architecture actually works. The volume nudge is reverted
* afterwards.
*/
class MpdClientIntegrationTest {
private val host = System.getenv("MPD_HOST")
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
private val password = System.getenv("MPD_PASSWORD")
@@ -35,7 +34,9 @@ class MpdClientIntegrationTest {
// Initial state, primed during connect().
val initial = withTimeout(5_000) { client.status.first { it != null } }!!
println("connected. state=${initial.state} volume=${initial.volume} song='${client.currentSong.value?.title}'")
println(
"connected. state=${initial.state} volume=${initial.volume} song='${client.currentSong.value?.title}'"
)
val startVolume = initial.volume
if (startVolume == null) {
@@ -49,9 +50,10 @@ class MpdClientIntegrationTest {
client.setVolume(nudged)
// ...and wait for the change to come back via the idle connection.
val observed = withTimeout(5_000) {
client.status.first { it?.volume == nudged }
}!!
val observed =
withTimeout(5_000) {
client.status.first { it?.volume == nudged }
}!!
println("idle push observed: status flow now reports volume=${observed.volume}")
assertEquals(nudged, observed.volume)
@@ -1,26 +1,28 @@
package ca.ksamad.encore.mpd
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
/**
* Drives [MpdConnection] against in-memory streams, so the whole text protocol
* (greeting, key/value parsing, ACK handling, binary payloads) is exercised
* with no socket.
* Drives [MpdConnection] against in-memory streams, so the whole text protocol (greeting, key/value
* parsing, ACK handling, binary payloads) is exercised with no socket.
*/
class MpdConnectionTest {
private fun conn(
serverBytes: ByteArray,
out: ByteArrayOutputStream = ByteArrayOutputStream(),
) = MpdConnection(ByteArrayInputStream(serverBytes), out)
private fun conn(serverBytes: ByteArray, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
MpdConnection(ByteArrayInputStream(serverBytes), out)
private fun conn(serverText: String, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
conn(serverText.toByteArray(Charsets.UTF_8), out)
private fun conn(
serverText: String,
out: ByteArrayOutputStream = ByteArrayOutputStream(),
) = conn(serverText.toByteArray(Charsets.UTF_8), out)
@Test
fun handshake_parsesVersion() {
@@ -83,10 +85,11 @@ class MpdConnectionTest {
@Test
fun executeList_splitsResponsesOnListOk() {
// command_list_ok_begin: each sub-response ends with list_OK, then final OK.
val server = "OK MPD 0.23.5\n" +
"volume: 50\nstate: play\nlist_OK\n" +
"file: a.mp3\nTitle: A\nlist_OK\n" +
"OK\n"
val server =
"OK MPD 0.23.5\n" +
"volume: 50\nstate: play\nlist_OK\n" +
"file: a.mp3\nTitle: A\nlist_OK\n" +
"OK\n"
val out = ByteArrayOutputStream()
val c = conn(server, out)
c.handshake()
@@ -98,7 +101,10 @@ class MpdConnectionTest {
assertEquals("a.mp3", responses[1]["file"])
assertEquals("A", responses[1]["Title"])
assertTrue(out.toString("UTF-8").startsWith("command_list_ok_begin\nstatus\ncurrentsong\ncommand_list_end\n"))
assertTrue(
out.toString("UTF-8")
.startsWith("command_list_ok_begin\nstatus\ncurrentsong\ncommand_list_end\n")
)
}
@Test
@@ -4,7 +4,6 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class MpdProtocolTest {
@Test
fun quote_wrapsPlainArg() {
assertEquals("\"hello\"", MpdProtocol.quote("hello"))
@@ -8,19 +8,17 @@ import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* A *live* smoke test against a real MPD server. It is skipped unless `MPD_HOST`
* is set, so the ordinary (and Nix) build never touches the network:
*
* A *live* smoke test against a real MPD server. It is skipped unless `MPD_HOST` is set, so the
* ordinary (and Nix) build never touches the network:
* ```
* MPD_HOST=192.168.1.50 MPD_PORT=6600 [MPD_PASSWORD=secret] \
* ./gradlew testDebugUnitTest --tests '*MpdServerIntegrationTest' --rerun-tasks
* ```
*
* It connects, runs `status` / `currentsong` / `stats`, and prints the parsed
* models so you can eyeball that the protocol layer really talks to your server.
* It connects, runs `status` / `currentsong` / `stats`, and prints the parsed models so you can
* eyeball that the protocol layer really talks to your server.
*/
class MpdServerIntegrationTest {
private val host = System.getenv("MPD_HOST")
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
private val password = System.getenv("MPD_PASSWORD")
@@ -44,10 +42,14 @@ class MpdServerIntegrationTest {
println("status : $status")
val song = MpdSong.from(conn.execute(MpdCommands.currentSong()).toMap())
println("current : ${song?.let { "${it.artist}${it.title} (${it.uri})" } ?: "<nothing playing>"}")
println(
"current : ${song?.let { "${it.artist}${it.title} (${it.uri})" } ?: "<nothing playing>"}"
)
val stats = MpdStatistics.from(conn.execute(MpdCommands.stats()).toMap())
println("stats : ${stats.songs} songs, ${stats.albums} albums, ${stats.artists} artists")
println(
"stats : ${stats.songs} songs, ${stats.albums} albums, ${stats.artists} artists"
)
// Prove the raw commands list works too — a good connectivity sanity check.
val commands = conn.execute(MpdProtocol.command("commands")).getAll("command")
@@ -8,27 +8,27 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class MpdModelTest {
@Test
fun status_parsesTypicalPlayingSnapshot() {
val s = MpdStatus.from(
mapOf(
"volume" to "80",
"repeat" to "0",
"random" to "1",
"single" to "oneshot",
"consume" to "0",
"playlist" to "17",
"playlistlength" to "12",
"state" to "play",
"song" to "3",
"songid" to "104",
"elapsed" to "42.5",
"duration" to "215.3",
"bitrate" to "320",
"audio" to "44100:16:2",
val s =
MpdStatus.from(
mapOf(
"volume" to "80",
"repeat" to "0",
"random" to "1",
"single" to "oneshot",
"consume" to "0",
"playlist" to "17",
"playlistlength" to "12",
"state" to "play",
"song" to "3",
"songid" to "104",
"elapsed" to "42.5",
"duration" to "215.3",
"bitrate" to "320",
"audio" to "44100:16:2",
)
)
)
assertEquals(80, s.volume)
assertFalse(s.repeat)
assertTrue(s.random)
@@ -49,18 +49,19 @@ class MpdModelTest {
@Test
fun song_parsesTagsAndDuration() {
val song = MpdSong.from(
mapOf(
"file" to "music/nin/closer.flac",
"Title" to "Closer",
"Artist" to "Nine Inch Nails",
"Album" to "The Downward Spiral",
"Track" to "6",
"duration" to "374.146",
"Pos" to "5",
"Id" to "42",
)
)!!
val song =
MpdSong.from(
mapOf(
"file" to "music/nin/closer.flac",
"Title" to "Closer",
"Artist" to "Nine Inch Nails",
"Album" to "The Downward Spiral",
"Track" to "6",
"duration" to "374.146",
"Pos" to "5",
"Id" to "42",
)
)!!
assertEquals("music/nin/closer.flac", song.uri)
assertEquals("Closer", song.title)
assertEquals("Nine Inch Nails", song.artist)
@@ -82,12 +83,19 @@ class MpdModelTest {
@Test
fun response_splitSeparatesRepeatedSongBlocks() {
// Two songs from a playlistinfo-style response, split on "file".
val resp = MpdResponse(
listOf(
"file" to "a.mp3", "Title" to "A", "Pos" to "0", "Id" to "1",
"file" to "b.mp3", "Title" to "B", "Pos" to "1", "Id" to "2",
val resp =
MpdResponse(
listOf(
"file" to "a.mp3",
"Title" to "A",
"Pos" to "0",
"Id" to "1",
"file" to "b.mp3",
"Title" to "B",
"Pos" to "1",
"Id" to "2",
)
)
)
val songs = resp.split().mapNotNull { MpdSong.from(it) }
assertEquals(2, songs.size)
assertEquals("A", songs[0].title)
@@ -98,15 +106,16 @@ class MpdModelTest {
@Test
fun albums_parseGroupedByArtist() {
// `list album group albumartist` style output.
val resp = MpdResponse(
listOf(
"AlbumArtist" to "Pink Floyd",
"Album" to "Animals",
"Album" to "The Wall",
"AlbumArtist" to "Daft Punk",
"Album" to "Discovery",
val resp =
MpdResponse(
listOf(
"AlbumArtist" to "Pink Floyd",
"Album" to "Animals",
"Album" to "The Wall",
"AlbumArtist" to "Daft Punk",
"Album" to "Discovery",
)
)
)
val albums = MpdAlbum.listFrom(resp)
assertEquals(3, albums.size)
assertEquals(MpdAlbum("Animals", "Pink Floyd"), albums[0])
@@ -122,15 +131,16 @@ class MpdModelTest {
@Test
fun statistics_parseCounts() {
val stats = MpdStatistics.from(
mapOf(
"artists" to "312",
"albums" to "540",
"songs" to "8123",
"uptime" to "98765",
"db_update" to "1700000000",
val stats =
MpdStatistics.from(
mapOf(
"artists" to "312",
"albums" to "540",
"songs" to "8123",
"uptime" to "98765",
"db_update" to "1700000000",
)
)
)
assertEquals(312, stats.artists)
assertEquals(8123, stats.songs)
assertEquals(1700000000L, stats.dbUpdateEpochSeconds)