mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user