feat: improved album and settings pages

This commit is contained in:
2026-07-30 00:47:30 -04:00
parent 795673aff8
commit 7d4dd0ff00
11 changed files with 592 additions and 8 deletions
@@ -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<MpdAlbum> = 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<MpdSong> = 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
@@ -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()) {
@@ -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<MpdSong> =
runCatching { client.albumTracks(album, albumArtist) }.getOrDefault(emptyList())
/** One-shot server statistics fetch; returns null on any failure. */
suspend fun loadStatistics(): MpdStatistics? =
runCatching { client.statistics() }.getOrNull()
/** Jump to a queue entry by its stable song id. */
fun playQueueItem(songId: Int) = fire { playId(songId) }
@@ -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<List<MpdSong>?>(initialValue = null, album) {
value = vm.loadAlbumTracks(album.name, album.albumArtist)
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun flash(message: String) {
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch { snackbarHostState.showSnackbar(message) }
}
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)) } },
)
}
}
@@ -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<MpdAlbum?, String?>(
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<MpdAlbum?>(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,
@@ -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<String> {
status ?: return emptyList()
val pills = mutableListOf<String>()
status.audio?.split(":")?.takeIf { it.size >= 3 }?.let { parts ->
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
formatSampleFormat(parts[1])?.let { pills.add(it) }
formatChannels(parts[2])?.let { pills.add(it) }
}
status.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?) {
@@ -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()
}
@@ -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)
}
@@ -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<MpdStatistics?>(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))
}
@@ -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)
}
}
}
}