From 795673aff85d97159e8d61ed0ba7f6bf5e2163b4 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Mon, 27 Jul 2026 10:18:10 -0400 Subject: [PATCH] feat: clear the queue --- .../ca/ksamad/musicremote/mpd/MpdClient.kt | 16 +++++++++ .../ca/ksamad/musicremote/mpd/MpdCommands.kt | 21 +++++++---- .../playback/MpdConnectionManager.kt | 7 ++++ .../ca/ksamad/musicremote/ui/AlbumsScreen.kt | Bin 4108 -> 12161 bytes .../ksamad/musicremote/ui/NowPlayingScreen.kt | 2 +- .../ksamad/musicremote/ui/PlayerViewModel.kt | 3 ++ .../ca/ksamad/musicremote/ui/QueueScreen.kt | 34 ++++++++++++++++++ 7 files changed, 76 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt index 237fbff..855b2b1 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt @@ -161,6 +161,7 @@ class MpdClient( suspend fun playPos(pos: Int) = run(MpdCommands.playPos(pos)) suspend fun playId(songId: Int) = run(MpdCommands.playId(songId)) suspend fun stop() = run(MpdCommands.stop()) + suspend fun clearQueue() = run(MpdCommands.clear()) suspend fun next() = run(MpdCommands.next()) suspend fun previous() = run(MpdCommands.previous()) suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused)) @@ -195,6 +196,21 @@ class MpdClient( conn.execute(MpdCommands.play()) } + /** Append an album's tracks to the end of the queue; playback is untouched. */ + suspend fun queueAlbum(album: String, albumArtist: String?) = withCommand { conn -> + conn.execute(MpdCommands.findAddAlbum(album, albumArtist)) + } + + /** + * Insert an album right after the current track so it plays next. Falls back + * to a plain append when nothing is playing — there is no current song for the + * relative `"+0"` position to anchor to. + */ + suspend fun playAlbumNext(album: String, albumArtist: String?) = withCommand { conn -> + val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null + conn.execute(MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null)) + } + /** 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) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt index 15ac01b..bb0b30a 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt @@ -72,13 +72,22 @@ object MpdCommands { /** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */ fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist") - /** Append every track of an album to the queue (optionally scoped to an artist). */ - fun findAddAlbum(album: String, albumArtist: String?) = - if (albumArtist.isNullOrEmpty()) { - MpdProtocol.command("findadd", "album", album) - } else { - MpdProtocol.command("findadd", "album", album, "albumartist", albumArtist) + /** + * Append every track of an album to the queue (optionally scoped to an artist). + * + * When [position] is given it is passed through as `findadd`'s `position` + * argument, controlling where the tracks land. MPD accepts an absolute index + * or a relative one — `"+0"` inserts right after the currently playing song + * (i.e. "play next"). Omit it to append to the end of the queue. + */ + fun findAddAlbum(album: String, albumArtist: String?, position: String? = null): String { + val args = buildList { + add("album"); add(album) + if (!albumArtist.isNullOrEmpty()) { add("albumartist"); add(albumArtist) } + if (position != null) { add("position"); add(position) } } + return MpdProtocol.command("findadd", *args.toTypedArray()) + } /** First track of an album — used to resolve a URI for album-art lookup. */ fun findFirstTrack(album: String, albumArtist: String?) = diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt index 2550ea4..bb5b598 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/playback/MpdConnectionManager.kt @@ -121,6 +121,7 @@ class MpdConnectionManager(context: Context) { fun resume() = fire { pause(false) } fun pause() = fire { pause(true) } fun stop() = fire { stop() } + fun clearQueue() = fire { clearQueue() } fun next() = fire { next() } fun previous() = fire { previous() } fun togglePlayPause() = fire { togglePause() } @@ -135,6 +136,12 @@ class MpdConnectionManager(context: Context) { fun playAlbum(album: String, albumArtist: String?) = fire { playAlbum(album, albumArtist) } + /** Append an album to the end of the queue without changing playback. */ + fun queueAlbum(album: String, albumArtist: String?) = fire { queueAlbum(album, albumArtist) } + + /** Insert an album right after the current track so it plays next. */ + fun playAlbumNext(album: String, albumArtist: String?) = fire { playAlbumNext(album, albumArtist) } + /** Jump to a queue entry by its stable song id. */ fun playQueueItem(songId: Int) = fire { playId(songId) } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/AlbumsScreen.kt index df9e27dfaf5abfc6d84030b4237be61ead104e0d..75c173ba190c316525fe020ef6074505de4afd48 100644 GIT binary patch literal 12161 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 XSto1Dh)o8mUn3$kxlT}ssg?@>Nl$v_ diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt index 971dd75..6ede084 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt @@ -159,7 +159,7 @@ fun NowPlayingScreen( Spacer(Modifier.height(24.dp)) - CastIndicator(host = serverHost) + // CastIndicator(host = serverHost) VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) }) Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt index f897c2b..58a98fb 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt @@ -70,8 +70,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun setConsume(on: Boolean) = manager.setConsume(on) 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) suspend fun loadAlbums() = manager.loadAlbums() fun playQueueItem(songId: Int) = manager.playQueueItem(songId) + fun clearQueue() = manager.clearQueue() suspend fun loadQueue() = manager.loadQueue() } diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt index 4331b48..04c03f5 100644 --- a/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/QueueScreen.kt @@ -11,7 +11,9 @@ 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.DeleteSweep import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -21,10 +23,14 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -63,6 +69,10 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { } val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true) + // Guards the destructive "clear queue" action behind a confirmation dialog. + var confirmClear by remember { mutableStateOf(false) } + val queueNotEmpty = fullQueue?.isNotEmpty() == true + Scaffold( topBar = { TopAppBar( @@ -72,6 +82,13 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + actions = { + if (queueNotEmpty) { + IconButton(onClick = { confirmClear = true }) { + Icon(Icons.Filled.DeleteSweep, contentDescription = "Clear queue") + } + } + }, ) }, ) { innerPadding -> @@ -137,6 +154,23 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) { } } } + + if (confirmClear) { + AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text("Clear the queue?") }, + text = { Text("This removes every track from the queue and stops playback.") }, + confirmButton = { + TextButton(onClick = { + vm.clearQueue() + confirmClear = false + }) { Text("Clear") } + }, + dismissButton = { + TextButton(onClick = { confirmClear = false }) { Text("Cancel") } + }, + ) + } } /** Explains what will actually happen at the end of the list, given the modes. */