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