start a shuffled queue from the album and playlist shuffle buttons

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent db58887567
commit a8f1b30d67
6 changed files with 116 additions and 2 deletions
@@ -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<List<QueueEntry>, 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 {
@@ -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 },
)
}
@@ -38,4 +38,10 @@ class AlbumDetailViewModel(
playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong())
}
}
fun shuffle() {
viewModelScope.launch {
playbackController.shuffleAlbum(sourceId.first(), albumId)
}
}
}
@@ -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,
@@ -43,4 +43,10 @@ class PlaylistDetailViewModel(
playbackController.playPlaylist(sourceId.first(), playlistId, startIndex.toLong())
}
}
fun shuffle() {
viewModelScope.launch {
playbackController.shufflePlaylist(sourceId.first(), playlistId)
}
}
}
@@ -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,