keep the shuffle order stable when removing a track

Removing a track re-derived the permutation from (seed, size), so every
remaining track moved to a new position and the queue visibly reshuffled
(in an album-heavy playlist, the removed row and its neighbours were
replaced by other tracks from the same album).

Keep the permutation fixed over the domain it was created with and subtract
removals from it instead: `ShuffleState` holds the seed, the domain size and
the removed domain slots, kept in memory only. `flatContext`/`playContext`
skip those slots so the surviving tracks keep their positions; the DB still
stores just the seed and domain, so the old per-track `shuffle_order` rows
stay gone. A new shuffle or a library change clears the removal list; on
restart the removals are gone, so a queue that was edited falls back to
unshuffled rather than guessing an order.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 6d25b31a9a
commit 736e59fec6
2 changed files with 140 additions and 15 deletions
@@ -639,6 +639,59 @@ class QueueRepositoryTest {
assertEquals(setOf("s1", "s3"), range.map { it.item.song.id }.toSet())
}
@Test
fun removingAContextTrackWhileShuffledKeepsTheRemainingOrder() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 1)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(2L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.filterIndexed { index, _ -> index != 1 }, orderAfter)
}
@Test
fun severalRemovalsWhileShuffledKeepTheRemainingOrder() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(42L)
val before = repository.snapshot()
val orderBefore = (0 until before.contextSize).mapNotNull { repository.itemAt(before, it)?.song?.id }
repository.removeAt(before, 0)
repository.removeAt(repository.snapshot(), 0)
val after = repository.snapshot()
assertTrue(after.shuffled)
assertEquals(1L, after.contextSize)
val orderAfter = (0 until after.contextSize).mapNotNull { repository.itemAt(after, it)?.song?.id }
assertEquals(orderBefore.drop(2), orderAfter)
}
@Test
fun removingWhileShuffledKeepsTheOrderInverse() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.setShuffle(9L)
repository.removeAt(repository.snapshot(), 2)
val after = repository.snapshot()
assertEquals(3L, after.contextSize)
for (play in 0 until after.contextSize) {
assertEquals(play, after.playContext(after.flatContext(play)!!))
}
assertEquals((0L until after.contextSize).toList(), (0 until after.contextSize).map { after.flatContext(it)!! }.sorted())
}
private suspend fun seedLibrary() {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),