feat: improved album and settings pages
This commit is contained in:
@@ -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)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user