mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
fix shuffled queue edits: flat remap, pinned shuffle start, stable highlight
The previous commit assumed a removed/moved track only shifts play positions by one, but a shuffled context removal re-derives the permutation, so the current track's play position is a remap of its flat index. `removeAt` now maps the current's flat through the updated order (shuffled) or shifts it (unshuffled), and re-anchors the up-next block to the track the cursor lands on. This fixes removing any track while shuffled picking a random track and the anchor removal not advancing to the block. Restore "shuffle starts at the top": pick the seed so the requested start track maps to play position 0 (`pinnedSeed`), instead of leaving the current at its derived position. Applies to shuffle-play, tapping a track while shuffled, toggling shuffle on, and restore. The queue view's playing highlight now requires both the combined position and the song id, so a transient position mismatch no longer flashes the row above as playing.
This commit is contained in:
@@ -253,15 +253,13 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
val start = randomIndex(snap.contextSize, avoid)
|
||||
val seed = Random.nextLong()
|
||||
val position = Shuffle.toSequence(seed, snap.contextSize, start)
|
||||
queueRepository.setShuffle(seed)
|
||||
queueRepository.setShuffle(pinnedSeed(snap.contextSize, start))
|
||||
shuffleEnabled = true
|
||||
queueSourceId = entry.sourceId
|
||||
queueRepository.setCursor(position)
|
||||
queueRepository.setCursor(0)
|
||||
snap = queueRepository.snapshot()
|
||||
this@PlaybackController.snapshot = snap
|
||||
loadWindow(position, autoplay = true)
|
||||
loadWindow(0, autoplay = true)
|
||||
}
|
||||
|
||||
private fun randomIndex(
|
||||
@@ -273,6 +271,21 @@ class PlaybackController(
|
||||
return if (avoid != null && index == avoid) (index + 1L) % size else index
|
||||
}
|
||||
|
||||
// A seed whose permutation puts `flat` at play position 0, so the shuffled queue starts on the
|
||||
// track that should play instead of somewhere in the middle. Each random seed hits with
|
||||
// probability 1/size; fall back to a random seed for queues too large to search.
|
||||
private fun pinnedSeed(
|
||||
size: Long,
|
||||
flat: Long,
|
||||
): Long {
|
||||
if (size <= 1L) return Random.nextLong()
|
||||
repeat(SEED_PIN_TRIES) {
|
||||
val seed = Random.nextLong()
|
||||
if (Shuffle.toFlat(seed, size, 0L) == flat) return seed
|
||||
}
|
||||
return Random.nextLong()
|
||||
}
|
||||
|
||||
fun playAt(position: Long) {
|
||||
scope.launch {
|
||||
startLock.withLock {
|
||||
@@ -333,21 +346,21 @@ class PlaybackController(
|
||||
}
|
||||
// Derive the current track's new position from the edit. Resolving its song id after
|
||||
// the edit can match an identical song elsewhere (a duplicate in the context or the
|
||||
// block), so use positions throughout; a context removal can move the anchor, so read
|
||||
// the combined position back from the updated snapshot.
|
||||
// block), so use canonical positions throughout: a shuffled removal re-derives the
|
||||
// permutation at the smaller size, so the current track's play position is a remap of
|
||||
// its flat index, not a simple shift.
|
||||
var target =
|
||||
when {
|
||||
position == current && snapshot.shuffled && !wasUpNext -> {
|
||||
// The permutation is re-derived at the smaller size, so there is no stable
|
||||
// "next"; play whatever now fills the removed track's slot.
|
||||
val seed = snapshot.shuffleSeed!!
|
||||
val removedFlat = snapshot.flatContext(snapshot.contextPlay(position)) ?: 0L
|
||||
val newContextSize = (snapshot.contextSize - 1).coerceAtLeast(0)
|
||||
if (newContextSize == 0L) {
|
||||
0L
|
||||
} else {
|
||||
val slot = removedFlat.coerceIn(0, newContextSize - 1)
|
||||
updated.combined(Shuffle.toSequence(seed, newContextSize, slot))
|
||||
updated.combined(updated.playContext(slot) ?: 0L)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,16 +376,18 @@ class PlaybackController(
|
||||
}
|
||||
|
||||
else -> {
|
||||
val play = snapshot.contextPlay(current)
|
||||
val newPlay =
|
||||
if (!snapshot.isUpNext(position) && snapshot.contextPlay(position) < play) play - 1 else play
|
||||
updated.combined(newPlay)
|
||||
// The current is a context track. Map its flat index through the (possibly
|
||||
// re-derived) order, decremented when a context track before it was removed.
|
||||
val flat = snapshot.flatContext(snapshot.contextPlay(current)) ?: 0L
|
||||
val removedFlat =
|
||||
if (snapshot.isUpNext(position)) null else snapshot.flatContext(snapshot.contextPlay(position))
|
||||
val movedFlat = if (removedFlat != null && removedFlat < flat) flat - 1 else flat
|
||||
updated.combined(if (snapshot.shuffled) updated.playContext(movedFlat) ?: 0L else movedFlat)
|
||||
}
|
||||
}.coerceIn(0, updated.size - 1)
|
||||
// Removing the playing context track while shuffled leaves the block anchored to some
|
||||
// other track; re-anchor it to the track the cursor landed on so the queued tracks
|
||||
// still play next rather than being stranded behind the cursor.
|
||||
if (snapshot.shuffled && !wasUpNext && position == current && updated.upNextSize > 0L) {
|
||||
// Keep the up-next block next: after a removal the cursor may have landed on a context
|
||||
// track that no longer carries the block (its anchor moved), so re-anchor it there.
|
||||
if (updated.upNextSize > 0L && !updated.isUpNext(target)) {
|
||||
val reanchored = queueRepository.reanchorFor(updated, target)
|
||||
if (reanchored.changed) {
|
||||
updated = readSnapshot()
|
||||
@@ -762,9 +777,8 @@ class PlaybackController(
|
||||
val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock
|
||||
windowJob?.cancel()
|
||||
val enabling = !shuffleEnabled
|
||||
val seed = if (enabling) Random.nextLong() else 0L
|
||||
if (enabling) {
|
||||
queueRepository.setShuffle(seed)
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
||||
} else {
|
||||
queueRepository.setShuffle(null)
|
||||
}
|
||||
@@ -774,7 +788,7 @@ class PlaybackController(
|
||||
val position =
|
||||
when {
|
||||
wasUpNext -> reordered.anchorPlay + 1 + blockIndex
|
||||
enabling -> reordered.combined(Shuffle.toSequence(seed, reordered.contextSize, flat))
|
||||
enabling -> reordered.combined(0L)
|
||||
else -> reordered.combined(flat)
|
||||
}
|
||||
val anchor = queueRepository.reanchorFor(reordered, position)
|
||||
@@ -844,9 +858,8 @@ class PlaybackController(
|
||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||
val position =
|
||||
if (shuffleEnabled) {
|
||||
val seed = Random.nextLong()
|
||||
queueRepository.setShuffle(seed)
|
||||
Shuffle.toSequence(seed, snapshot.contextSize, start)
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
||||
0L
|
||||
} else {
|
||||
start
|
||||
}
|
||||
@@ -868,9 +881,9 @@ class PlaybackController(
|
||||
var snapshot = queueRepository.snapshot()
|
||||
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
|
||||
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
|
||||
val seed = Random.nextLong()
|
||||
queueRepository.setShuffle(seed)
|
||||
queueRepository.setCursor(Shuffle.toSequence(seed, snapshot.contextSize, start))
|
||||
val flat = snapshot.flatContext(start) ?: start
|
||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, flat))
|
||||
queueRepository.setCursor(0)
|
||||
snapshot = queueRepository.snapshot()
|
||||
} else if (!snapshot.shuffled) {
|
||||
shuffleEnabled = false
|
||||
@@ -1235,5 +1248,6 @@ class PlaybackController(
|
||||
const val QUEUE_WINDOW_RADIUS = 25L
|
||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||
const val RESTART_THRESHOLD_MS = 3_000L
|
||||
const val SEED_PIN_TRIES = 1_000_000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ fun QueueRoute(
|
||||
initialIndex = viewModel.initialIndex,
|
||||
generation = viewModel.generation,
|
||||
currentPosition = playback.position,
|
||||
currentSongId = playback.item?.id,
|
||||
shuffle = playback.shuffle,
|
||||
contextTitle = sourceTitle.value,
|
||||
onClearUpNext = controller::clearUpNext,
|
||||
@@ -307,6 +308,7 @@ fun QueueScreen(
|
||||
rows: List<QueueRow>,
|
||||
ready: Boolean,
|
||||
currentPosition: Long?,
|
||||
currentSongId: String?,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (Long) -> Unit,
|
||||
@@ -434,7 +436,7 @@ fun QueueScreen(
|
||||
) { isDragging ->
|
||||
QueueRowItem(
|
||||
row = row,
|
||||
isPlaying = row.position == currentPosition,
|
||||
isPlaying = row.position == currentPosition && row.song.song.id == currentSongId,
|
||||
floating = isDragging,
|
||||
coverArt = coverArt,
|
||||
dragHandle =
|
||||
|
||||
@@ -2003,6 +2003,76 @@ class PlaybackControllerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shufflePlayStartsAtTheTopOfTheQueue() {
|
||||
seedAlbum(20, sourceId = 1)
|
||||
|
||||
controller.shuffleAlbum(1, "al1")
|
||||
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||
|
||||
assertEquals(0L, controller.state.value.position)
|
||||
val snapshot = runBlocking { queues.snapshot() }
|
||||
assertEquals(
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
runBlocking { queues.itemAt(snapshot, 0)?.song?.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enablingShufflePinsThePlayingTrackToTheTop() {
|
||||
seedAlbum(20, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 7)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s8"
|
||||
}
|
||||
|
||||
controller.toggleShuffle()
|
||||
await { controller.state.value.shuffle }
|
||||
|
||||
assertEquals(0L, controller.state.value.position)
|
||||
assertEquals(
|
||||
"s8",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingAContextTrackWhileShuffledKeepsThePlayingTrackAndTheBlock() {
|
||||
seedAlbum(6, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
controller.toggleShuffle()
|
||||
await { controller.state.value.shuffle && runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
val currentId =
|
||||
controller.state.value.item!!
|
||||
.id
|
||||
val before = runBlocking { queues.snapshot() }
|
||||
val currentPosition = controller.state.value.position!!
|
||||
val removePosition = (0 until before.size).first { !before.isUpNext(it) && it != currentPosition }
|
||||
|
||||
runBlocking { controller.removeAt(removePosition) }
|
||||
|
||||
await {
|
||||
val snap = runBlocking { queues.snapshot() }
|
||||
controller.state.value.item
|
||||
?.id == currentId &&
|
||||
runBlocking { queues.itemAt(snap, controller.state.value.position!!)?.song?.id } == currentId
|
||||
}
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shufflingTheSameAlbumAgainPicksADifferentTrack() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
|
||||
@@ -416,6 +416,7 @@ class AppScreenshotTest {
|
||||
Fixtures.songItems.mapIndexed { index, item -> QueueRow(index.toLong(), index.toLong(), item) },
|
||||
ready = true,
|
||||
currentPosition = 1L,
|
||||
currentSongId = Fixtures.songItems[1].song.id,
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onBack = {},
|
||||
onPlay = {},
|
||||
|
||||
Reference in New Issue
Block a user