feat: implement shuffle play functionality for albums, playlists, and saved tracks

This commit is contained in:
Kingkor Roy Tirtho 2026-09-25 18:47:29 +06:00
parent 50c3b04eb1
commit 78168d269c
11 changed files with 53 additions and 28 deletions

View File

@ -36,9 +36,9 @@ class CollectionPlaybackHelper(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
) { ) {
suspend fun playAlbum(albumId: String) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
if (audioPlayerQueue.isAlbumPlaying(albumId)) return if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId) val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
@ -91,9 +91,9 @@ class CollectionPlaybackHelper(
) )
} }
suspend fun playPlaylist(playlistId: String) { suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return
val entries = fetchAllPlaylistTracks(playlistId) val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
@ -146,9 +146,9 @@ class CollectionPlaybackHelper(
) )
} }
suspend fun playSavedTracks() { suspend fun playSavedTracks(shuffle: Boolean = false) {
if (audioPlayerQueue.isSavedTracksPlaying()) return if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return
val entries = fetchAllSavedTracks() val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,

View File

@ -172,7 +172,7 @@ class RemoteControlHandler(
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) { when (val command = envelope.command) {
is RemoteControlCommand.Play -> { is RemoteControlCommand.Play -> {
handleCollectionSource(command.source, RemoteCollectionAction.Play) handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle)
} }
is RemoteControlCommand.Pause -> { is RemoteControlCommand.Pause -> {
audioPlayer.pause() audioPlayer.pause()
@ -297,13 +297,17 @@ class RemoteControlHandler(
* Resolves a `spotube://` collection source URI (playlist/album/artist top * Resolves a `spotube://` collection source URI (playlist/album/artist top
* tracks/saved tracks) and applies the requested action on the remote queue. * tracks/saved tracks) and applies the requested action on the remote queue.
*/ */
private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { private suspend fun handleCollectionSource(
source: String,
action: RemoteCollectionAction,
shuffle: Boolean = false,
) {
logger.d { "Remote collection $action for source: $source" } logger.d { "Remote collection $action for source: $source" }
when { when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
} }
@ -312,7 +316,7 @@ class RemoteControlHandler(
source.startsWith(COLLECTION_ALBUM_PREFIX) -> { source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
} }
@ -329,7 +333,7 @@ class RemoteControlHandler(
source == COLLECTION_SAVED_TRACKS -> { source == COLLECTION_SAVED_TRACKS -> {
when (action) { when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
RemoteCollectionAction.PlayNext -> { RemoteCollectionAction.PlayNext -> {
// Saved tracks "play next" is not supported; add to queue instead // Saved tracks "play next" is not supported; add to queue instead
@ -467,4 +471,4 @@ enum class ConnectionRequestResponse {
Allow, Allow,
AllowAlways, AllowAlways,
Deny, Deny,
} }

View File

@ -25,7 +25,7 @@ import kotlinx.serialization.Serializable
sealed class RemoteControlCommand { sealed class RemoteControlCommand {
@Serializable @Serializable
@SerialName("play") @SerialName("play")
data class Play(val source: String) : RemoteControlCommand() data class Play(val source: String, val shuffle: Boolean = false) : RemoteControlCommand()
@Serializable @Serializable
@SerialName("pause") @SerialName("pause")
@ -147,4 +147,4 @@ data class RemoteQueueEntry(
val album: String?, val album: String?,
val coverUrl: String?, val coverUrl: String?,
val durationMs: Long, val durationMs: Long,
) )

View File

@ -64,6 +64,7 @@ sealed interface PlaybackDestinationRequest {
val type: RemoteCollectionType, val type: RemoteCollectionType,
val id: String, val id: String,
val startTrack: MetadataTrack? = null, val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false,
) : PlaybackDestinationRequest ) : PlaybackDestinationRequest
data class Track( data class Track(
@ -112,8 +113,9 @@ class RemotePlaybackController(
id: String, id: String,
title: String, title: String,
startTrack: MetadataTrack? = null, startTrack: MetadataTrack? = null,
shuffle: Boolean = false,
) { ) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
} }
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
@ -239,7 +241,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
} else { } else {
collectionPlaybackHelper.playPlaylist(request.id) collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
} }
} }
@ -252,7 +254,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else { } else {
collectionPlaybackHelper.playAlbum(request.id) collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
} }
} }
@ -271,7 +273,7 @@ class RemotePlaybackController(
if (startTrack != null) { if (startTrack != null) {
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
} else { } else {
collectionPlaybackHelper.playSavedTracks() collectionPlaybackHelper.playSavedTracks(request.shuffle)
} }
} }
@ -346,7 +348,7 @@ class RemotePlaybackController(
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
} }
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
} }
@ -385,4 +387,4 @@ class RemotePlaybackController(
album?.id == other.album?.id && album?.id == other.album?.id &&
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
} }
} }

View File

@ -78,7 +78,7 @@ fun AlbumScreen(
} }
}, },
onPlay = viewModel::playAlbum, onPlay = viewModel::playAlbum,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlayAlbum,
onAddToQueue = viewModel::addAlbumToQueue, onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),

View File

@ -224,6 +224,11 @@ class AlbumViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
} }
fun shufflePlayAlbum() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true)
}
fun addAlbumToQueue() { fun addAlbumToQueue() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)

View File

@ -111,7 +111,7 @@ fun PlaylistScreen(
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = {}, onOwnerClick = {},
onPlay = viewModel::playPlaylist, onPlay = viewModel::playPlaylist,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlayPlaylist,
onAddToQueue = viewModel::addPlaylistToQueue, onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),

View File

@ -230,6 +230,11 @@ class PlaylistViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
} }
fun shufflePlayPlaylist() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true)
}
fun addPlaylistToQueue() { fun addPlaylistToQueue() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)

View File

@ -70,7 +70,7 @@ fun SavedTracksScreen(
ownerImageURL = null, ownerImageURL = null,
onOwnerClick = {}, onOwnerClick = {},
onPlay = viewModel::playSavedTracks, onPlay = viewModel::playSavedTracks,
onShufflePlay = {}, onShufflePlay = viewModel::shufflePlaySavedTracks,
onAddToQueue = viewModel::addSavedTracksToQueue, onAddToQueue = viewModel::addSavedTracksToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = false, isFollowing = false,

View File

@ -206,6 +206,15 @@ class SavedTracksViewModel(
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
} }
fun shufflePlaySavedTracks() {
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.SavedTracks,
SAVED_TRACKS_COLLECTION_ID,
"Saved Tracks",
shuffle = true,
)
}
fun addSavedTracksToQueue() { fun addSavedTracksToQueue() {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
} }
@ -377,4 +386,4 @@ fun rememberIsSavedTracks(
} }
return state return state
} }

View File

@ -47,7 +47,7 @@ materialKolor = "4.1.1"
mqttClient = "2.1.1" mqttClient = "2.1.1"
mqttBuffer = "6.30.8" mqttBuffer = "6.30.8"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6" nucleusNucleusApplication = "2.4.6"
semver = "2.1.0" semver = "2.1.0"