From 7d4dd0ff009fe728561cfc1fbb427651c05b69f8 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Mon, 27 Jul 2026 11:39:57 -0400 Subject: [PATCH] feat: improved album and settings pages --- .../ca/ksamad/musicremote/mpd/MpdClient.kt | 33 +++ .../ca/ksamad/musicremote/mpd/MpdCommands.kt | 20 +- .../playback/MpdConnectionManager.kt | 15 ++ .../musicremote/ui/AlbumDetailScreen.kt | 219 ++++++++++++++++++ .../ca/ksamad/musicremote/ui/AlbumsScreen.kt | Bin 12161 -> 9482 bytes .../ksamad/musicremote/ui/MusicRemoteApp.kt | 40 +++- .../ksamad/musicremote/ui/NowPlayingScreen.kt | 75 ++++++ .../ksamad/musicremote/ui/PlayerViewModel.kt | 5 + .../ca/ksamad/musicremote/ui/QueueScreen.kt | 3 +- .../ksamad/musicremote/ui/SettingsScreen.kt | 83 +++++++ .../ca/ksamad/musicremote/ui/SwipeActions.kt | 107 +++++++++ 11 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt index 855b2b1..8530267 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt @@ -2,6 +2,7 @@ package ca.ksamad.musicremote.mpd import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.musicremote.mpd.model.MpdSong +import ca.ksamad.musicremote.mpd.model.MpdStatistics import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.musicremote.mpd.model.PlayerState import kotlinx.coroutines.CoroutineDispatcher @@ -162,6 +163,19 @@ class MpdClient( suspend fun playId(songId: Int) = run(MpdCommands.playId(songId)) suspend fun stop() = run(MpdCommands.stop()) suspend fun clearQueue() = run(MpdCommands.clear()) + + /** Append a single track to the end of the queue; playback is untouched. */ + suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri)) + + /** + * Insert a single track right after the current one so it plays next. Falls + * back to a plain append when nothing is playing — no current song to anchor + * the relative `"+0"` position to. + */ + suspend fun playTrackNext(uri: String) = withCommand { conn -> + val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null + conn.execute(MpdCommands.add(uri, position = if (playing) "+0" else null)) + } suspend fun next() = run(MpdCommands.next()) suspend fun previous() = run(MpdCommands.previous()) suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused)) @@ -184,6 +198,11 @@ class MpdClient( conn.execute(MpdCommands.playlistInfo()).split().mapNotNull { MpdSong.from(it) } } + /** Database/server statistics (`stats`). */ + suspend fun statistics(): MpdStatistics = withCommand { conn -> + MpdStatistics.from(conn.execute(MpdCommands.stats()).toMap()) + } + /** Every album in the library. */ suspend fun albums(): List = withCommand { conn -> MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums())) @@ -211,6 +230,13 @@ class MpdClient( conn.execute(MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null)) } + /** Every track of an album, ordered by disc then track number. */ + suspend fun albumTracks(album: String, albumArtist: String?): List = withCommand { conn -> + conn.execute(MpdCommands.findAlbumTracks(album, albumArtist)) + .split().mapNotNull { MpdSong.from(it) } + .sortedWith(compareBy({ it.disc.leadingInt() }, { it.track.leadingInt() })) + } + /** Cover art bytes for a specific song URI, or null if the server has none. */ suspend fun songArt(uri: String): ByteArray? = withArtConnection { conn -> readArt(conn, uri) } @@ -451,3 +477,10 @@ class MpdClient( private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist") } } + +/** + * Leading integer of a `track`/`disc` tag for sorting — these can carry values + * like `"3/12"`, so we read the digits up front. Missing/blank tags sort last. + */ +private fun String?.leadingInt(): Int = + this?.takeWhile { it.isDigit() }?.toIntOrNull() ?: Int.MAX_VALUE diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt index bb0b30a..7c4ca02 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt @@ -64,7 +64,17 @@ object MpdCommands { fun playlistInfo() = MpdProtocol.command("playlistinfo") fun clear() = MpdProtocol.command("clear") - fun add(uri: String) = MpdProtocol.command("add", uri) + /** + * Add a single file/URI to the queue. With no [position] it appends to the + * end; otherwise the value is passed as `add`'s positional argument — `"+0"` + * inserts right after the currently playing song ("play next"). + */ + fun add(uri: String, position: String? = null) = + if (position == null) { + MpdProtocol.command("add", uri) + } else { + MpdProtocol.command("add", uri, position) + } fun deleteId(songId: Int) = MpdProtocol.command("deleteid", songId.toString()) // --- Database / library ------------------------------------------------- @@ -89,6 +99,14 @@ object MpdCommands { return MpdProtocol.command("findadd", *args.toTypedArray()) } + /** Every track of an album (optionally scoped to an artist), in database order. */ + fun findAlbumTracks(album: String, albumArtist: String?) = + if (albumArtist.isNullOrEmpty()) { + MpdProtocol.command("find", "album", album) + } else { + MpdProtocol.command("find", "album", album, "albumartist", albumArtist) + } + /** First track of an album — used to resolve a URI for album-art lookup. */ fun findFirstTrack(album: String, albumArtist: String?) = if (albumArtist.isNullOrEmpty()) { diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt index bb5b598..17e7df6 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt @@ -7,6 +7,7 @@ import ca.ksamad.musicremote.mpd.MpdClient import ca.ksamad.musicremote.mpd.MpdConnectionState import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.musicremote.mpd.model.MpdSong +import ca.ksamad.musicremote.mpd.model.MpdStatistics import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -142,6 +143,20 @@ class MpdConnectionManager(context: Context) { /** Insert an album right after the current track so it plays next. */ fun playAlbumNext(album: String, albumArtist: String?) = fire { playAlbumNext(album, albumArtist) } + /** Append a single track to the end of the queue. */ + fun queueTrack(uri: String) = fire { queueTrack(uri) } + + /** Insert a single track right after the current one so it plays next. */ + fun playTrackNext(uri: String) = fire { playTrackNext(uri) } + + /** One-shot album track-list fetch; returns empty on any failure. */ + suspend fun loadAlbumTracks(album: String, albumArtist: String?): List = + runCatching { client.albumTracks(album, albumArtist) }.getOrDefault(emptyList()) + + /** One-shot server statistics fetch; returns null on any failure. */ + suspend fun loadStatistics(): MpdStatistics? = + runCatching { client.statistics() }.getOrNull() + /** Jump to a queue entry by its stable song id. */ fun playQueueItem(songId: Int) = fire { playId(songId) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt new file mode 100644 index 0000000..6358dba --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumDetailScreen.kt @@ -0,0 +1,219 @@ +package ca.ksamad.musicremote.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ca.ksamad.musicremote.mpd.model.MpdAlbum +import ca.ksamad.musicremote.mpd.model.MpdSong +import ca.ksamad.musicremote.playback.AlbumArt +import ca.ksamad.musicremote.playback.SongArt +import kotlinx.coroutines.launch + +/** + * One album, in detail: its cover, whole-album Play / Add-to-queue actions, and + * the track list. Tapping Play replaces the queue and starts the album (leaving + * this screen); swiping a track queues it (right) or plays it next (left), with + * the same semantics as swiping an album in the library. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumDetailScreen( + vm: PlayerViewModel, + album: MpdAlbum, + onBack: () -> Unit, + onPlay: () -> Unit, +) { + // null = still loading the track list. + val tracks by produceState?>(initialValue = null, album) { + value = vm.loadAlbumTracks(album.name, album.albumArtist) + } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + fun flash(message: String) { + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { snackbarHostState.showSnackbar(message) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(album.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + item { + AlbumDetailHeader( + album = album, + onPlay = { + vm.playAlbum(album.name, album.albumArtist) + onPlay() + }, + onQueue = { + vm.queueAlbum(album.name, album.albumArtist) + flash("Added “${album.name}” to the queue") + }, + ) + } + + val loaded = tracks + when { + loaded == null -> item { + Box( + Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } + + loaded.isEmpty() -> item { + Box( + Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No tracks found", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> items(loaded, key = { it.uri }) { track -> + TrackRow( + track = track, + onQueue = { + vm.queueTrack(track.uri) + flash("Added “${track.title ?: track.uri}” to the queue") + }, + onPlayNext = { + vm.playTrackNext(track.uri) + flash("“${track.title ?: track.uri}” will play next") + }, + ) + } + } + } + } +} + +/** Cover, title/artist, and the two whole-album action buttons. */ +@Composable +private fun AlbumDetailHeader( + album: MpdAlbum, + onPlay: () -> Unit, + onQueue: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ArtImage( + model = AlbumArt(album.name, album.albumArtist), + iconSize = 72.dp, + modifier = Modifier.size(220.dp).clip(RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.size(16.dp)) + Text( + album.name, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + album.albumArtist?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.size(16.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = onPlay) { + Icon(Icons.Filled.PlayArrow, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Play") + } + FilledTonalIconButton(onClick = onQueue) { + Icon(Icons.Filled.Add, contentDescription = "Add album to queue") + } + } + } +} + +/** + * A single track row. Swiping right queues the track, swiping left plays it next + * — the same gesture as the album library, scoped to this one track. + */ +@Composable +private fun TrackRow( + track: MpdSong, + onQueue: () -> Unit, + onPlayNext: () -> Unit, +) { + QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) { + ListItem( + leadingContent = { + ArtImage( + model = SongArt(track.uri), + iconSize = 18.dp, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + ) + }, + headlineContent = { + Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = track.artist?.let { + { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } + }, + trailingContent = track.duration?.let { { Text(formatDuration(it)) } }, + ) + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt index 75c173ba190c316525fe020ef6074505de4afd48..44af066ffdc9ff2eb7a2eb3ad6b957013526a3e6 100644 GIT binary patch delta 830 zcmZpS@A8`9IeMyvecqVh4PHdlGNgY#N<@n;?#n~ zqQsKa6or!1qFjZ<^u)}(;u5f9CO_a6spnFF0)2gjlKk}4l8n?M9fjh2g+!3Miz*e8 z6Y~^`3lj4blJZM36w)$Nb5e>GG?bE36H9VZixm=+l2R*`G!+u_QWVPbi&Buy%P&ev zEmBCyPc6<l$xGep`jELQ>?9|si~KinNyNlq)@GpS)!L&?3Z8Snp;p(siCP*J2_v# zffeM_;>jBXtm>gbt!oFj73y0BknI_XmBl5A$=M3F3Q8)~;8@YiOUzBJRe6 znZ+dv_Eri?O0`PJuEQ`DY_ncsPL4vgLPlbx2-tYN%;=_usq1>_`F`hij5pIaAD delta 2732 zcmeD3YK))Y%9WIuoSj~jUz(RPQL&9NX!1c0=}Ao76Bn?v1Q#SGPrkqg;a-@`&&bMB zo|#fI*^pUk5)=34JjO0YX2+D2iHeew3z+yPe`K;|56DTZbSx?Y83Gd9tja9PBp6(t zS&$l%?~+-Zn^|1!lwSeTVZh2IiXjn}m{U4&qts*r*3FX*Sh-}2Qgc&tlTwQ?6b6?h zmVgxUv2kpE&AN%Xz9_XICows-SfL~%RiUsnwKP?sJhLQ2p(G=*giAq7Au%VZG*=-p zFGZoaB(bQZSfL;%u`)9+T_LkX2c#!Yp(wSav?#Awp(G!~SIEmR*M+GmPA*DK&C}yj z&;l8y;RrFSsH8lDXB#YnaTNidYY5}ah8IE%t}FHb32zWyoldwva^t&W@d?AUSe*lUUGh3Nn&PRu|}beLS}kieo?A(VsWa1 ztwKprX{x3|P0i$kLZTKLnI(GQ0B|fS$t*6hC#205s(*97Ks@JUXCcYS9|Tw?|B>RE zY$RpFk)H>OWev^A2Zf|2_ezU_Bsc$+{K+VTB@#d(4hqKN$^0^+JfPsq&r8)U&d4vB zY#^h;YXuTf&{Ze~C6UPyGTL$qsd*_1`Dx%d0LP;>ijrO#m&q(##*-Zsc_zP*si_Br zRSG0wfzkxX>v$7Qss@(=6eQ>8rDYc7g3?^7b4FrbI!JT1LP=s#dTNP+t{q$|2$w>uUUp+ZbKw|O(QI>jreT9&W)Z$cy#G+J% z#N?99{Jdfvg}nR{g_P8sR4~^{AuqKowMd~DtRIvk5*46E%B?`6*)sPT#1pD3zUI1z!8=(i~ zxMUWkg4Jm%K%KOCq3ju^`jCuNh2;F4{L-S-6osPHved*Ja8}P#NJ`Df%u7*71gC0H z83WFHpt8V9Au&%u$uT7b6sF+Rt)!5VnOCBap9f0ipt36`H8BOGCp9HKRY3!sFBFP0 z(=$pm!C{!FpaeVRY9R>&Q~-cO z66AAR1)?m6XH2j`AQPbFXljvytwN?)nt}$*NF*0dE|8E`FHTL)&r3-xssyE`#LT?Z zBCr{?3aL58sYvBQfrPYcNor9^CZbIZ)hNlnoMB~P$#P((qg&atQ{zZ~xToW!Km9CV}V(UUjWBxsriC0R(Ufg&4h z5Gad6VjUWiAOR3YO#yJVU@1hN)__MGG>xc&6euVt=oKWUfbx+>Mt)Id6{wa3In~HS zFQq_Jhimd|ap@>fT17;&V@_szUT$h$iJo(6UP)>Zq~ry=9$m^cF9lM*ASDikp!{-; zGEmqjC+19kC?=yE2Ib^bA}a$q4{q_~{{rHipitHT#meLcQSth`(wrO}g%VJiV5@+X zK%m|OX@_D^>nybhnq^Xp^gvCs3=IpA2Qd|gfGQ4fgz10_El@hgX8Po-LUQTEDuy~l dL4hQvp!io3r++8s3&?LyR+!JUnMe5_BLK#&YQO*h diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt index 75d5193..461bc8c 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import ca.ksamad.musicremote.data.ConnectionSettings +import ca.ksamad.musicremote.mpd.model.MpdAlbum /** * App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a @@ -34,15 +36,26 @@ import ca.ksamad.musicremote.data.ConnectionSettings /** Sub-screens layered over the player (no nav library needed for this few). */ private enum class PlayerOverlay { None, Settings, Albums, Queue } +/** Saves the drilled-into album (name + artist) across config change/process death. */ +private val AlbumSaver = listSaver( + save = { listOf(it?.name, it?.albumArtist) }, + restore = { saved -> saved[0]?.let { name -> MpdAlbum(name, saved[1]) } }, +) + @Composable fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { val screen by vm.screen.collectAsStateWithLifecycle() var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) } + // When set (within the Albums overlay), the album detail screen is shown. + var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf(null) } // Overlays only make sense over the player; leaving it (e.g. after a // reset/disconnect) drops us back to the normal screen flow. LaunchedEffect(screen) { - if (screen !is AppScreen.Player) overlay = PlayerOverlay.None + if (screen !is AppScreen.Player) { + overlay = PlayerOverlay.None + detailAlbum = null + } } when (val s = screen) { @@ -50,11 +63,26 @@ fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error) is AppScreen.Player -> when (overlay) { PlayerOverlay.Settings -> SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None }) - PlayerOverlay.Albums -> AlbumsScreen( - vm, - onBack = { overlay = PlayerOverlay.None }, - onPlay = { overlay = PlayerOverlay.None }, - ) + PlayerOverlay.Albums -> { + val selected = detailAlbum + if (selected != null) { + AlbumDetailScreen( + vm, + album = selected, + onBack = { detailAlbum = null }, + onPlay = { + detailAlbum = null + overlay = PlayerOverlay.None + }, + ) + } else { + AlbumsScreen( + vm, + onBack = { overlay = PlayerOverlay.None }, + onOpenAlbum = { detailAlbum = it }, + ) + } + } PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None }) PlayerOverlay.None -> NowPlayingScreen( vm, diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt index 6ede084..ff65c7e 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt @@ -1,9 +1,12 @@ package ca.ksamad.musicremote.ui +import androidx.compose.foundation.background import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -122,6 +125,8 @@ fun NowPlayingScreen( overflow = TextOverflow.Ellipsis, ) + AudioPropertyPills(status = status) + Spacer(Modifier.height(32.dp)) SeekBar(status = status, onSeek = { vm.seekTo(it) }) @@ -244,6 +249,76 @@ private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) { } } +/** + * Little pills describing the current stream's audio format — sample rate, bit + * depth, channels, and (live) bitrate — derived from MPD's `audio`/`bitrate` + * status fields. Renders nothing when there's no format info (e.g. stopped). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AudioPropertyPills(status: MpdStatus?) { + val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) } + if (pills.isEmpty()) return + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + pills.forEach { pill -> + Text( + text = pill, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } +} + +/** + * Build the audio-format pill labels from a status snapshot. MPD's `audio` field + * is `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can + * be `f` (float) or `dsd` rather than a bit depth. Only well-formed parts appear. + */ +private fun audioPropertyPills(status: MpdStatus?): List { + status ?: return emptyList() + val pills = mutableListOf() + + status.audio?.split(":")?.takeIf { it.size >= 3 }?.let { parts -> + parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) } + formatSampleFormat(parts[1])?.let { pills.add(it) } + formatChannels(parts[2])?.let { pills.add(it) } + } + status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") } + + return pills +} + +private fun formatSampleRate(hz: Int): String { + val khz = hz / 1000.0 + val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz) + return "$value kHz" +} + +private fun formatSampleFormat(token: String): String? = when (token) { + "f" -> "Float" + "dsd" -> "DSD" + else -> token.toIntOrNull()?.let { "$it-bit" } +} + +private fun formatChannels(token: String): String? = when (token.toIntOrNull()) { + null -> null + 1 -> "Mono" + 2 -> "Stereo" + else -> "$token ch" +} + /** "Casting" affordance: signals that the volume below controls the server, not the device. */ @Composable private fun CastIndicator(host: String?) { diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt index 58a98fb..a38d4bc 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt @@ -72,9 +72,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun playAlbum(album: String, albumArtist: String?) = manager.playAlbum(album, albumArtist) fun queueAlbum(album: String, albumArtist: String?) = manager.queueAlbum(album, albumArtist) fun playAlbumNext(album: String, albumArtist: String?) = manager.playAlbumNext(album, albumArtist) + fun queueTrack(uri: String) = manager.queueTrack(uri) + fun playTrackNext(uri: String) = manager.playTrackNext(uri) + suspend fun loadAlbumTracks(album: String, albumArtist: String?) = + manager.loadAlbumTracks(album, albumArtist) suspend fun loadAlbums() = manager.loadAlbums() fun playQueueItem(songId: Int) = manager.playQueueItem(songId) fun clearQueue() = manager.clearQueue() + suspend fun loadStatistics() = manager.loadStatistics() suspend fun loadQueue() = manager.loadQueue() } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt index 04c03f5..2a75cdc 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt @@ -182,7 +182,8 @@ private fun playbackModeCaption(status: MpdStatus?): String? = when { else -> null } -private fun formatDuration(seconds: Double): String { +/** `m:ss` for a duration in seconds. Shared across the queue/album track lists. */ +internal fun formatDuration(seconds: Double): String { val total = seconds.toInt().coerceAtLeast(0) return "%d:%02d".format(total / 60, total % 60) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt index 0243add..e368f0b 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SettingsScreen.kt @@ -7,11 +7,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -26,11 +30,16 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ca.ksamad.musicremote.mpd.model.MpdStatistics +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Settings: shows the current server and offers to switch servers or reset all @@ -43,6 +52,14 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) { val status by vm.status.collectAsStateWithLifecycle() var confirmReset by remember { mutableStateOf(false) } + // Server statistics (`stats`), fetched once when the screen opens. `null` + // means either still loading or unavailable — [statsLoaded] disambiguates. + var statsLoaded by remember { mutableStateOf(false) } + val stats by produceState(initialValue = null) { + value = vm.loadStatistics() + statsLoaded = true + } + Scaffold( topBar = { TopAppBar( @@ -59,6 +76,7 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) { modifier = Modifier .fillMaxSize() .padding(innerPadding) + .verticalScroll(rememberScrollState()) .padding(horizontal = 8.dp), ) { Text( @@ -97,6 +115,36 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) { }, ) + Text( + "Server statistics", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp), + ) + when { + !statsLoaded -> ListItem( + headlineContent = { Text("Loading…") }, + trailingContent = { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + }, + ) + + stats == null -> ListItem( + headlineContent = { Text("Statistics unavailable") }, + supportingContent = { Text("The server didn't report any statistics") }, + ) + + else -> stats?.let { s -> + StatRow("Artists", formatCount(s.artists)) + StatRow("Albums", formatCount(s.albums)) + StatRow("Tracks", formatCount(s.songs)) + StatRow("Library length", formatStatDuration(s.dbPlaytimeSeconds)) + StatRow("Time played", formatStatDuration(s.playtimeSeconds)) + StatRow("Server uptime", formatStatDuration(s.uptimeSeconds)) + StatRow("Last updated", formatTimestamp(s.dbUpdateEpochSeconds)) + } + } + Spacer(Modifier.height(24.dp)) OutlinedButton( @@ -141,3 +189,38 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) { ) } } + +/** A label/value line matching the Host/Port rows above. */ +@Composable +private fun StatRow(label: String, value: String) { + ListItem( + headlineContent = { Text(label) }, + trailingContent = { Text(value) }, + ) +} + +/** Thousands-grouped count, e.g. `12,345`. */ +private fun formatCount(n: Int): String = "%,d".format(n) + +/** + * A duration in seconds as a compact `Xd Yh Zm`, dropping leading zero units but + * always showing at least minutes. Non-positive values render as an em dash. + */ +private fun formatStatDuration(totalSeconds: Long): String { + if (totalSeconds <= 0) return "—" + val days = totalSeconds / 86_400 + val hours = (totalSeconds % 86_400) / 3_600 + val minutes = (totalSeconds % 3_600) / 60 + return buildString { + if (days > 0) append("${days}d ") + if (days > 0 || hours > 0) append("${hours}h ") + append("${minutes}m") + } +} + +/** A Unix epoch (seconds) as a localized date-time, or an em dash if unset. */ +private fun formatTimestamp(epochSeconds: Long): String { + if (epochSeconds <= 0) return "—" + val format = SimpleDateFormat("MMM d, yyyy HH:mm", Locale.getDefault()) + return format.format(Date(epochSeconds * 1000)) +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt new file mode 100644 index 0000000..10b79e6 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/SwipeActions.kt @@ -0,0 +1,107 @@ +package ca.ksamad.musicremote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Wraps [content] in the app's shared "queue swipe" affordance: swipe right to + * [onAddToQueue] (append), swipe left to [onPlayNext] (insert after the current + * track). Used for both album rows and individual tracks so the gesture and its + * meaning stay identical wherever it appears. + * + * Both are one-shot actions, not deletions, so the row always springs back and + * stays in the list — [content] should be opaque (e.g. a `ListItem`) so it hides + * the coloured reveal once settled. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QueueSwipeRow( + onAddToQueue: () -> Unit, + onPlayNext: () -> Unit, + content: @Composable () -> Unit, +) { + val state = rememberSwipeToDismissBoxState( + confirmValueChange = { target -> + when (target) { + SwipeToDismissBoxValue.StartToEnd -> onAddToQueue() + SwipeToDismissBoxValue.EndToStart -> onPlayNext() + SwipeToDismissBoxValue.Settled -> {} + } + false // Never settle to dismissed — snap back and keep the row. + }, + ) + + SwipeToDismissBox( + state = state, + backgroundContent = { SwipeActionBackground(state.dismissDirection) }, + ) { + content() + } +} + +/** + * The coloured reveal shown behind a swiping row: an "Add to queue" hint on the + * leading edge (swipe right) and a "Play next" hint on the trailing edge (swipe + * left). Renders empty while the row is settled. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) { + if (direction == SwipeToDismissBoxValue.Settled) { + Box(Modifier.fillMaxSize()) + return + } + + val queueing = direction == SwipeToDismissBoxValue.StartToEnd + val container = if (queueing) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.tertiaryContainer + } + val onContainer = if (queueing) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onTertiaryContainer + } + val icon = if (queueing) Icons.Filled.Add else Icons.Filled.PlayArrow + val label = if (queueing) "Add to queue" else "Play next" + + Box( + Modifier + .fillMaxSize() + .background(container) + .padding(horizontal = 24.dp), + contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (queueing) { + Icon(icon, contentDescription = null, tint = onContainer) + Spacer(Modifier.width(8.dp)) + Text(label, color = onContainer) + } else { + Text(label, color = onContainer) + Spacer(Modifier.width(8.dp)) + Icon(icon, contentDescription = null, tint = onContainer) + } + } + } +}