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