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