From a8f1b30d67d15938617d02fd8cab2229acdf8516 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:58:31 +0000 Subject: [PATCH] start a shuffled queue from the album and playlist shuffle buttons --- .../subtracks/playback/PlaybackController.kt | 67 ++++++++++++++++++- .../subtracks/ui/library/AlbumDetailScreen.kt | 1 + .../ui/library/AlbumDetailViewModel.kt | 6 ++ .../ui/library/PlaylistDetailScreen.kt | 4 +- .../ui/library/PlaylistDetailViewModel.kt | 6 ++ .../playback/PlaybackControllerTest.kt | 34 ++++++++++ 6 files changed, 116 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 4b4663e8..73a14d6f 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlin.random.Random data class QueueItem( val id: String, @@ -98,6 +99,7 @@ class PlaybackController( private var player: PlayerHandle? = null private var connecting = false private var pendingPlay: Pair, Long>? = null + private var pendingShuffle: QueueEntry? = null private var positionJob: Job? = null private var windowJob: Job? = null private var bufferingJob: Job? = null @@ -139,7 +141,13 @@ class PlaybackController( scope.launch { val pending = pendingPlay pendingPlay = null - if (pending != null) start(pending.first, pending.second) else restore() + val shuffle = pendingShuffle + pendingShuffle = null + when { + pending != null -> start(pending.first, pending.second) + shuffle != null -> startShuffled(shuffle) + else -> restore() + } } }, onDisconnected = { @@ -167,6 +175,63 @@ class PlaybackController( startPosition: Long, ) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition) + fun shuffleAlbum( + sourceId: Long, + albumId: String, + ) = shufflePlay(queueRepository.albumEntry(sourceId, albumId)) + + fun shufflePlaylist( + sourceId: Long, + playlistId: String, + ) = shufflePlay(queueRepository.playlistEntry(sourceId, playlistId)) + + private fun shufflePlay(entry: QueueEntry) { + if (player == null) { + pendingShuffle = entry + connect() + return + } + scope.launch { startShuffled(entry) } + } + + private suspend fun startShuffled(entry: QueueEntry) = + startLock.withLock { + windowJob?.cancel() + windowStart = 0 + windowEnd = -1 + lastDurationMs = 0 + lastError = null + lastEdit = null + endedHandled = false + val previous = snapshot + val previousEntry = previous?.entries?.firstOrNull()?.entry + val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId + val avoid = if (same) currentPosition()?.let { previous.flatPosition(it) } else null + queueRepository.replace(listOf(entry)) + var snap = queueRepository.snapshot() + if (snap.size == 0L) { + stopLocked() + return@withLock + } + val start = randomIndex(snap.size, avoid) + val order = withContext(Dispatchers.Default) { shuffledOrder(snap.size, start) } + queueRepository.setShuffle(true, order) + shuffleEnabled = true + queueRepository.setCursor(0) + snap = queueRepository.snapshot() + this@PlaybackController.snapshot = snap + loadWindow(0, autoplay = true) + } + + private fun randomIndex( + size: Long, + avoid: Long?, + ): Long { + if (size <= 1L) return 0L + val index = Random.nextLong(size) + return if (avoid != null && index == avoid) (index + 1L) % size else index + } + fun playAt(position: Long) { scope.launch { startLock.withLock { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 56100790..2fcc6213 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -43,6 +43,7 @@ fun AlbumDetailRoute( artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), onBack = onBack, onSongClick = viewModel::play, + onShuffle = viewModel::shuffle, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index 8ee3fae7..4fb2411c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -38,4 +38,10 @@ class AlbumDetailViewModel( playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong()) } } + + fun shuffle() { + viewModelScope.launch { + playbackController.shuffleAlbum(sourceId.first(), albumId) + } + } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 5a563526..6a767430 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -41,6 +41,7 @@ fun PlaylistDetailRoute( artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), onBack = onBack, onSongClick = viewModel::play, + onShuffle = viewModel::shuffle, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, ) } @@ -53,6 +54,7 @@ fun PlaylistDetailScreen( artwork: ArtworkColors?, onBack: () -> Unit, onSongClick: (Int) -> Unit, + onShuffle: () -> Unit = {}, playingSongId: String? = null, modifier: Modifier = Modifier, ) { @@ -69,7 +71,7 @@ fun PlaylistDetailScreen( comment = playlist?.comment, hasSongs = songs.itemCount > 0, onPlay = { onSongClick(0) }, - onShuffle = {}, + onShuffle = onShuffle, onDownload = {}, onMore = {}, topInset = topInset, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index 57763370..2c6e60be 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -43,4 +43,10 @@ class PlaylistDetailViewModel( playbackController.playPlaylist(sourceId.first(), playlistId, startIndex.toLong()) } } + + fun shuffle() { + viewModelScope.launch { + playbackController.shufflePlaylist(sourceId.first(), playlistId) + } + } } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 4346b3c0..e82d1300 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -840,6 +840,40 @@ class PlaybackControllerTest { await { controller.state.value.isPlaying } } + @Test + fun shufflePlayStartsShuffledOnARandomTrack() { + seedAlbum(5, sourceId = 1) + + controller.shuffleAlbum(1, "al1") + await { controller.state.value.item != null && controller.state.value.shuffle } + + assertTrue(controller.state.value.shuffle) + assertEquals(0L, controller.state.value.position) + assertTrue(handle.items.first().id in listOf("s1", "s2", "s3", "s4", "s5")) + } + + @Test + fun shufflingTheSameAlbumAgainPicksADifferentTrack() { + seedAlbum(5, sourceId = 1) + + controller.shuffleAlbum(1, "al1") + await { controller.state.value.item != null } + val first = + controller.state.value.item + ?.id + + controller.shuffleAlbum(1, "al1") + await { + controller.state.value.item + ?.id != first + } + + assertTrue( + controller.state.value.item + ?.id != first, + ) + } + private fun seedAlbum( count: Int, sourceId: Long,