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.MpdAlbum
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
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.MpdStatus
|
||||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
import ca.ksamad.musicremote.mpd.model.PlayerState
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -162,6 +163,19 @@ class MpdClient(
|
|||||||
suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))
|
suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))
|
||||||
suspend fun stop() = run(MpdCommands.stop())
|
suspend fun stop() = run(MpdCommands.stop())
|
||||||
suspend fun clearQueue() = run(MpdCommands.clear())
|
suspend fun clearQueue() = run(MpdCommands.clear())
|
||||||
|
|
||||||
|
/** 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 next() = run(MpdCommands.next())
|
||||||
suspend fun previous() = run(MpdCommands.previous())
|
suspend fun previous() = run(MpdCommands.previous())
|
||||||
suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))
|
suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))
|
||||||
@@ -184,6 +198,11 @@ class MpdClient(
|
|||||||
conn.execute(MpdCommands.playlistInfo()).split().mapNotNull { MpdSong.from(it) }
|
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. */
|
/** Every album in the library. */
|
||||||
suspend fun albums(): List<MpdAlbum> = withCommand { conn ->
|
suspend fun albums(): List<MpdAlbum> = withCommand { conn ->
|
||||||
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
||||||
@@ -211,6 +230,13 @@ class MpdClient(
|
|||||||
conn.execute(MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null))
|
conn.execute(MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every track of an album, ordered by disc then track number. */
|
||||||
|
suspend fun albumTracks(album: String, albumArtist: String?): List<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. */
|
/** 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) }
|
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")
|
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 playlistInfo() = MpdProtocol.command("playlistinfo")
|
||||||
fun clear() = MpdProtocol.command("clear")
|
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())
|
fun deleteId(songId: Int) = MpdProtocol.command("deleteid", songId.toString())
|
||||||
|
|
||||||
// --- Database / library -------------------------------------------------
|
// --- Database / library -------------------------------------------------
|
||||||
@@ -89,6 +99,14 @@ object MpdCommands {
|
|||||||
return MpdProtocol.command("findadd", *args.toTypedArray())
|
return MpdProtocol.command("findadd", *args.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every track of an album (optionally scoped to an artist), in database order. */
|
||||||
|
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. */
|
/** First track of an album — used to resolve a URI for album-art lookup. */
|
||||||
fun findFirstTrack(album: String, albumArtist: String?) =
|
fun findFirstTrack(album: String, albumArtist: String?) =
|
||||||
if (albumArtist.isNullOrEmpty()) {
|
if (albumArtist.isNullOrEmpty()) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ca.ksamad.musicremote.mpd.MpdClient
|
|||||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||||
|
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -142,6 +143,20 @@ class MpdConnectionManager(context: Context) {
|
|||||||
/** Insert an album right after the current track so it plays next. */
|
/** Insert an album right after the current track so it plays next. */
|
||||||
fun playAlbumNext(album: String, albumArtist: String?) = fire { playAlbumNext(album, albumArtist) }
|
fun playAlbumNext(album: String, albumArtist: String?) = fire { playAlbumNext(album, albumArtist) }
|
||||||
|
|
||||||
|
/** Append a single track to the end of the queue. */
|
||||||
|
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. */
|
/** Jump to a queue entry by its stable song id. */
|
||||||
fun playQueueItem(songId: Int) = fire { playId(songId) }
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
import ca.ksamad.musicremote.data.ConnectionSettings
|
||||||
|
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
|
* 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). */
|
/** Sub-screens layered over the player (no nav library needed for this few). */
|
||||||
private enum class PlayerOverlay { None, Settings, Albums, Queue }
|
private enum class PlayerOverlay { None, Settings, Albums, Queue }
|
||||||
|
|
||||||
|
/** Saves the drilled-into album (name + artist) across config change/process death. */
|
||||||
|
private val AlbumSaver = listSaver<MpdAlbum?, String?>(
|
||||||
|
save = { listOf(it?.name, it?.albumArtist) },
|
||||||
|
restore = { saved -> saved[0]?.let { name -> MpdAlbum(name, saved[1]) } },
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
||||||
val screen by vm.screen.collectAsStateWithLifecycle()
|
val screen by vm.screen.collectAsStateWithLifecycle()
|
||||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
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
|
// Overlays only make sense over the player; leaving it (e.g. after a
|
||||||
// reset/disconnect) drops us back to the normal screen flow.
|
// reset/disconnect) drops us back to the normal screen flow.
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
if (screen !is AppScreen.Player) overlay = PlayerOverlay.None
|
if (screen !is AppScreen.Player) {
|
||||||
|
overlay = PlayerOverlay.None
|
||||||
|
detailAlbum = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val s = screen) {
|
when (val s = screen) {
|
||||||
@@ -50,11 +63,26 @@ fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
|
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
|
||||||
is AppScreen.Player -> when (overlay) {
|
is AppScreen.Player -> when (overlay) {
|
||||||
PlayerOverlay.Settings -> SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
PlayerOverlay.Settings -> SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
||||||
PlayerOverlay.Albums -> AlbumsScreen(
|
PlayerOverlay.Albums -> {
|
||||||
|
val selected = detailAlbum
|
||||||
|
if (selected != null) {
|
||||||
|
AlbumDetailScreen(
|
||||||
|
vm,
|
||||||
|
album = selected,
|
||||||
|
onBack = { detailAlbum = null },
|
||||||
|
onPlay = {
|
||||||
|
detailAlbum = null
|
||||||
|
overlay = PlayerOverlay.None
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AlbumsScreen(
|
||||||
vm,
|
vm,
|
||||||
onBack = { overlay = PlayerOverlay.None },
|
onBack = { overlay = PlayerOverlay.None },
|
||||||
onPlay = { overlay = PlayerOverlay.None },
|
onOpenAlbum = { detailAlbum = it },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
PlayerOverlay.Queue -> QueueScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
||||||
PlayerOverlay.None -> NowPlayingScreen(
|
PlayerOverlay.None -> NowPlayingScreen(
|
||||||
vm,
|
vm,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package ca.ksamad.musicremote.ui
|
package ca.ksamad.musicremote.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -122,6 +125,8 @@ fun NowPlayingScreen(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AudioPropertyPills(status = status)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
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. */
|
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CastIndicator(host: String?) {
|
private fun CastIndicator(host: String?) {
|
||||||
|
|||||||
@@ -72,9 +72,14 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
fun playAlbum(album: String, albumArtist: String?) = manager.playAlbum(album, albumArtist)
|
fun playAlbum(album: String, albumArtist: String?) = manager.playAlbum(album, albumArtist)
|
||||||
fun queueAlbum(album: String, albumArtist: String?) = manager.queueAlbum(album, albumArtist)
|
fun queueAlbum(album: String, albumArtist: String?) = manager.queueAlbum(album, albumArtist)
|
||||||
fun playAlbumNext(album: String, albumArtist: String?) = manager.playAlbumNext(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()
|
suspend fun loadAlbums() = manager.loadAlbums()
|
||||||
|
|
||||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||||
fun clearQueue() = manager.clearQueue()
|
fun clearQueue() = manager.clearQueue()
|
||||||
|
suspend fun loadStatistics() = manager.loadStatistics()
|
||||||
suspend fun loadQueue() = manager.loadQueue()
|
suspend fun loadQueue() = manager.loadQueue()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ private fun playbackModeCaption(status: MpdStatus?): String? = when {
|
|||||||
else -> null
|
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)
|
val total = seconds.toInt().coerceAtLeast(0)
|
||||||
return "%d:%02d".format(total / 60, total % 60)
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -26,11 +30,16 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ca.ksamad.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
|
* 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()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
var confirmReset by remember { mutableStateOf(false) }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -59,6 +76,7 @@ fun SettingsScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
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