rework: derive the shuffle order from a seed on top of the up-next queue

Replaces the per-track `shuffle_order` table and the retained whole-order
arrays with `playback_cursor.shuffleSeed`/`shuffleSize` and a seekable
Feistel permutation of `[0, size)` (`Shuffle.kt`). Positions map through
`flatContext`/`playContext`; a snapshot is shuffled only while the stored
size matches the context, so a stale size is ignored. Queue migration
16->17 adds `shuffleSize` and drops `shuffle_order`.

Drops the "dragging a context row while shuffled reorders the canonical
queue" behaviour: it cannot be made WYSIWYG without materialising the
order. The context is playback-only while shuffled (the view already
disables context drags) and `QueueRepository.move` no-ops for a context
move while shuffled; manual ordering goes through the up-next block.

Removing a context track while shuffled re-derives the permutation at the
smaller size, keeping the same seed and the playhead on the current track,
or on whatever now fills the removed track's slot when it was playing.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 0a11585205
commit 93742e8584
13 changed files with 1646 additions and 138 deletions
@@ -2,6 +2,7 @@ package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.room3.useReaderConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -440,15 +441,20 @@ class QueueRepositoryTest {
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, longArrayOf(2, 0, 1))
repository.setShuffle(true, 12345L)
val shuffled = repository.snapshot()
val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals("s5", repository.itemAt(updated, 1)?.song?.id)
assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated))
assertEquals("s5", repository.itemAt(updated, updated.anchorPlay + 1)?.song?.id)
val contextAfter =
(0 until updated.size)
.filterNot { updated.isUpNext(it) }
.mapNotNull { repository.itemAt(updated, it)?.song?.id }
assertEquals(contextBefore, contextAfter)
assertTrue(updated.shuffled)
}
@@ -535,11 +541,11 @@ class QueueRepositoryTest {
(0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id }
@Test
fun aStaleShuffleOrderIsIgnoredWhenTheQueueLengthChanges() =
fun aStaleShuffleSizeIsIgnoredWhenTheQueueLengthChanges() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, longArrayOf(2, 0, 1))
repository.setShuffle(true, 1234L)
assertTrue(repository.snapshot().shuffled)
db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album")))
@@ -548,6 +554,91 @@ class QueueRepositoryTest {
assertFalse(repository.snapshot().shuffled)
}
@Test
fun enablingShuffleStoresNoPerTrackRows() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
val entriesBefore = db.queueDao().entries().size
repository.setShuffle(true, 5678L)
val snapshot = repository.snapshot()
assertTrue(snapshot.shuffled)
assertEquals(3L, snapshot.contextSize)
assertEquals(entriesBefore, db.queueDao().entries().size)
assertFalse(shuffleOrderTableExists())
}
private suspend fun shuffleOrderTableExists(): Boolean =
db.useReaderConnection { transactor ->
transactor.usePrepared(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'shuffle_order'",
) { statement ->
if (statement.step()) statement.getLong(0) > 0 else false
}
}
@Test
fun shuffledPositionsResolveToEveryTrackExactlyOnce() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, 42L)
val shuffled = repository.snapshot()
val resolved = (0 until shuffled.size).mapNotNull { repository.itemAt(shuffled, it)?.song?.id }
assertEquals(3, resolved.distinct().size)
assertEquals(listOf("s1", "s2", "s3"), resolved.sorted())
val flatPositions = (0 until shuffled.size).map { shuffled.flatContext(it)!! }
assertEquals((0L until shuffled.size).toList(), flatPositions.sorted())
}
@Test
fun togglingShuffleOffRestoresTheOriginalOrder() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, 99L)
assertTrue(repository.snapshot().shuffled)
repository.setShuffle(false, 0L)
val restored = repository.snapshot()
assertFalse(restored.shuffled)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
}
@Test
fun shuffledRangeResolvesEntriesInPositionOrder() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.setShuffle(true, 42L)
val snapshot = repository.snapshot()
val range = repository.range(snapshot, 0, snapshot.size - 1)
assertEquals((0L until snapshot.size).toList(), range.map { it.position })
assertEquals(setOf("s1", "s2", "s3", "s4"), range.map { it.item.song.id }.toSet())
}
@Test
fun shuffledRangeResolvesASplitEntryWithoutRepeatingOrSkipping() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(snapshot, 1)
repository.setShuffle(true, 7L)
val shuffled = repository.snapshot()
val range = repository.range(shuffled, 0, shuffled.size - 1)
assertEquals(listOf(0L, 1L), range.map { it.position })
assertEquals(setOf("s1", "s3"), range.map { it.item.song.id }.toSet())
}
private suspend fun seedLibrary() {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -0,0 +1,113 @@
package com.subtracks.data.repo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ShuffleTest {
private val sizes =
listOf(
1L,
2L,
3L,
4L,
5L,
7L,
8L,
9L,
15L,
16L,
17L,
31L,
32L,
33L,
63L,
64L,
65L,
100L,
127L,
128L,
129L,
255L,
256L,
257L,
999L,
1000L,
1023L,
1024L,
1025L,
4096L,
65535L,
65536L,
65537L,
100_000L,
999_983L,
)
private val seeds = listOf(0L, 1L, -1L, 42L, Long.MIN_VALUE, Long.MAX_VALUE, 0x5DEECE66DL)
@Test
fun mapsEverySizeAsABijection() {
for (size in sizes) {
for (seed in seeds) {
val mapped = (0 until size).map { Shuffle.toFlat(seed, size, it) }
assertEquals("size=$size seed=$seed has duplicates", mapped.distinct().size, size.toInt())
assertTrue("size=$size seed=$seed out of range", mapped.all { it in 0 until size })
for (sequence in 0 until size) {
assertEquals(
"size=$size seed=$seed sequence=$sequence did not round trip",
sequence,
Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence)),
)
}
}
}
}
@Test
fun mapsLargeSizesAsABijectionOnSamples() {
for (size in listOf(1_000_003L, 16_777_216L, 16_777_217L, 1_000_000_000L)) {
for (seed in seeds) {
val samples = (0 until 5000).map { size / 5000 * it + it % 97 }
val mapped = samples.map { Shuffle.toFlat(seed, size, it) }
assertEquals(mapped.distinct().size, mapped.size)
assertTrue(mapped.all { it in 0 until size })
samples.forEach { sequence ->
assertEquals(sequence, Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence)))
}
}
}
}
@Test
fun spreadsTheFirstPositionAcrossSeeds() {
val size = 1000L
val buckets = LongArray(10)
val draws = 2000
for (i in 0 until draws) {
val flat = Shuffle.toFlat(i.toLong() * 2654435761L + 17L, size, 0L)
buckets[(flat * buckets.size / size).toInt()]++
}
val expected = draws / buckets.size
buckets.forEach { count ->
assertTrue("bucket $count is far from $expected", count in expected / 2..expected * 2)
}
}
@Test
fun doesNotLeaveMostTracksInPlace() {
val size = 1000L
for (seed in seeds) {
val moved = (0 until size).count { Shuffle.toFlat(seed, size, it) != it }
assertTrue("seed=$seed left too many tracks in place", moved > size * 9 / 10)
}
}
@Test
fun consecutivePositionsAreNotASingleStride() {
val size = 1000L
val order = (0 until size).map { Shuffle.toFlat(7L, size, it) }
val strides = order.zipWithNext { a, b -> ((b - a) % size + size) % size }.toSet()
assertTrue("the order looks like one fixed stride", strides.size > size / 2)
}
}
@@ -17,6 +17,7 @@ import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.Shuffle
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
@@ -1526,12 +1527,13 @@ class PlaybackControllerTest {
controller.state.value.item
?.id,
)
assertEquals(0L, controller.state.value.position)
assertEquals("s3", handle.items.first().id)
val shuffled = runBlocking { queues.snapshot() }
assertEquals("s3", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id })
controller.toggleShuffle()
await { !controller.state.value.shuffle }
assertEquals(2L, controller.state.value.position)
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
assertEquals(
"s3",
@@ -1540,6 +1542,109 @@ class PlaybackControllerTest {
)
}
@Test
fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() {
seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
val currentId =
controller.state.value.item!!
.id
val removeAt = (controller.state.value.position!! + 1) % 6
runBlocking { controller.removeAt(removeAt) }
await {
controller.state.value.item
?.id == currentId && controller.state.value.shuffle
}
assertTrue(handle.items.map { it.id }.contains(currentId))
val remaining =
runBlocking {
val snap = queues.snapshot()
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
assertEquals(5, remaining.size)
assertEquals(5, remaining.distinct().size)
}
@Test
fun removingTheCurrentTrackWhileShuffledPlaysTheSongThatFillsItsSlot() {
seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
val current = controller.state.value.position!!
val before = runBlocking { queues.snapshot() }
val removedFlat = before.flatContext(current)!!
runBlocking { controller.removeAt(current) }
val after = runBlocking { queues.snapshot() }
val newContextSize = (before.contextSize - 1).coerceAtLeast(0)
val slot = removedFlat.coerceIn(0, (newContextSize - 1).coerceAtLeast(0))
val expected = after.combined(Shuffle.toSequence(before.shuffleSeed!!, newContextSize, slot))
assertEquals(5L, after.size)
assertEquals(expected, controller.state.value.position)
assertEquals(
runBlocking { queues.itemAt(after, expected)?.song?.id },
controller.state.value.item
?.id,
)
}
@Test
fun movingContextTracksWhileShuffledIsIgnored() {
seedAlbum(6, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
val currentId =
controller.state.value.item!!
.id
val before =
runBlocking {
val snap = queues.snapshot()
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
val position = controller.state.value.position!!
val from = (position + 1) % 6
val to = (position + 3) % 6
runBlocking { controller.move(from, to) }
val after =
runBlocking {
val snap = queues.snapshot()
(0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id }
}
assertEquals(before, after)
assertEquals(
currentId,
controller.state.value.item
?.id,
)
}
@Test
fun nextWrapsToTheStartWhenRepeating() {
seedAlbum(3, sourceId = 1)
@@ -1679,8 +1784,9 @@ class PlaybackControllerTest {
?.id == "s4"
}
assertEquals(0L, controller.state.value.position)
assertEquals("s4", handle.items.first().id)
assertEquals("s4", handle.currentItem?.id)
val shuffled = runBlocking { queues.snapshot() }
assertEquals("s4", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id })
}
@Test
@@ -1761,8 +1867,15 @@ class PlaybackControllerTest {
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"))
assertTrue(
controller.state.value.item!!
.id in listOf("s1", "s2", "s3", "s4", "s5"),
)
assertEquals(
controller.state.value.item!!
.id,
handle.currentItem?.id,
)
}
@Test