mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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:
+1112
File diff suppressed because it is too large
Load Diff
@@ -163,6 +163,14 @@ val MIGRATION_15_16 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_16_17 =
|
||||
object : Migration(16, 17) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleSize` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("DROP TABLE IF EXISTS `shuffle_order`")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rebuildTable(
|
||||
connection: SQLiteConnection,
|
||||
table: String,
|
||||
@@ -252,4 +260,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
MIGRATION_15_16,
|
||||
MIGRATION_16_17,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.ShuffleOrder
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
|
||||
@@ -41,15 +40,6 @@ interface QueueDao {
|
||||
@Query("SELECT * FROM up_next_entries ORDER BY position, id")
|
||||
suspend fun upNextEntries(): List<UpNextEntry>
|
||||
|
||||
@Query("SELECT * FROM shuffle_order ORDER BY sequence")
|
||||
suspend fun shuffleOrder(): List<ShuffleOrder>
|
||||
|
||||
@Query("DELETE FROM shuffle_order")
|
||||
suspend fun clearShuffleOrder()
|
||||
|
||||
@Insert
|
||||
suspend fun insertShuffleOrder(entries: List<ShuffleOrder>)
|
||||
|
||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||
suspend fun cursor(): PlaybackCursor?
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKindConverter
|
||||
import com.subtracks.data.model.ShuffleOrder
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
@@ -31,10 +30,9 @@ import com.subtracks.data.model.UpNextEntry
|
||||
QueueEntry::class,
|
||||
UpNextEntry::class,
|
||||
PlaybackCursor::class,
|
||||
ShuffleOrder::class,
|
||||
ArtworkSeed::class,
|
||||
],
|
||||
version = 16,
|
||||
version = 17,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
|
||||
@@ -305,16 +305,11 @@ data class PlaybackCursor(
|
||||
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
|
||||
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val shuffleSize: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val positionMs: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0,
|
||||
)
|
||||
|
||||
@Entity(tableName = "shuffle_order")
|
||||
data class ShuffleOrder(
|
||||
@PrimaryKey val sequence: Long,
|
||||
val flatPosition: Long,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artwork_seeds",
|
||||
foreignKeys = [
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.ShuffleOrder
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -33,7 +32,7 @@ private data class EntryLengthKey(
|
||||
private data class LoadedSnapshot(
|
||||
val entries: List<ResolvedQueueEntry>,
|
||||
val upNext: List<ResolvedQueueEntry>,
|
||||
val shuffleOrder: LongArray?,
|
||||
val shuffleSeed: Long?,
|
||||
val anchor: Long,
|
||||
)
|
||||
|
||||
@@ -45,7 +44,7 @@ data class ResolvedQueueEntry(
|
||||
data class QueueSnapshot(
|
||||
val entries: List<ResolvedQueueEntry>,
|
||||
val upNext: List<ResolvedQueueEntry> = emptyList(),
|
||||
val shuffleOrder: LongArray? = null,
|
||||
val shuffleSeed: Long? = null,
|
||||
val upNextAnchor: Long = 0,
|
||||
val version: Long = 0,
|
||||
) {
|
||||
@@ -55,13 +54,14 @@ data class QueueSnapshot(
|
||||
|
||||
val size: Long = contextSize + upNextSize
|
||||
|
||||
val shuffled: Boolean get() = shuffleOrder != null
|
||||
val shuffled: Boolean get() = shuffleSeed != null
|
||||
|
||||
val anchorPlay: Long =
|
||||
(
|
||||
when (val order = shuffleOrder) {
|
||||
null -> upNextAnchor
|
||||
else -> if (upNextAnchor < 0L) -1L else order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L
|
||||
when {
|
||||
shuffleSeed == null -> upNextAnchor
|
||||
upNextAnchor < 0L -> -1L
|
||||
else -> playContext(upNextAnchor)
|
||||
}
|
||||
).coerceAtMost(contextSize - 1L)
|
||||
|
||||
@@ -75,11 +75,15 @@ data class QueueSnapshot(
|
||||
|
||||
fun combined(contextPlay: Long): Long = if (contextPlay <= anchorPlay) contextPlay else contextPlay + upNextSize
|
||||
|
||||
fun flatContext(contextPlay: Long): Long? =
|
||||
when (val order = shuffleOrder) {
|
||||
null -> contextPlay.takeIf { it in 0 until contextSize }
|
||||
else -> order.getOrNull(contextPlay.toInt())
|
||||
}
|
||||
fun flatContext(contextPlay: Long): Long? {
|
||||
if (contextPlay !in 0 until contextSize) return null
|
||||
return shuffleSeed?.let { Shuffle.toFlat(it, contextSize, contextPlay) } ?: contextPlay
|
||||
}
|
||||
|
||||
fun playContext(flat: Long): Long {
|
||||
if (flat !in 0 until contextSize) return flat
|
||||
return shuffleSeed?.let { Shuffle.toSequence(it, contextSize, flat) } ?: flat
|
||||
}
|
||||
|
||||
fun locate(position: Long): Pair<QueueEntry, Long>? {
|
||||
if (isUpNext(position)) return locateIn(upNext, upNextIndex(position))
|
||||
@@ -134,12 +138,6 @@ class QueueRepository(
|
||||
) {
|
||||
private val dao get() = db.queueDao()
|
||||
|
||||
@Volatile
|
||||
private var cachedShuffleSeed = 0L
|
||||
|
||||
@Volatile
|
||||
private var cachedShuffleOrder: LongArray? = null
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
|
||||
@Volatile
|
||||
@@ -204,13 +202,13 @@ class QueueRepository(
|
||||
|
||||
suspend fun replace(
|
||||
entries: List<QueueEntry>,
|
||||
shuffleOrder: LongArray? = null,
|
||||
shuffleSeed: Long? = null,
|
||||
upNext: List<QueueEntry> = emptyList(),
|
||||
upNextAnchor: Long = 0,
|
||||
) {
|
||||
write(entries)
|
||||
writeUpNext(upNext)
|
||||
setShuffle(shuffleOrder != null, shuffleOrder)
|
||||
setShuffle(shuffleSeed != null, shuffleSeed ?: 0L)
|
||||
cursorMutex.withLock {
|
||||
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
|
||||
}
|
||||
@@ -228,11 +226,12 @@ class QueueRepository(
|
||||
ResolvedQueueEntry(entry, lengthOf(entry, version))
|
||||
}
|
||||
val row = dao.cursor()
|
||||
val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
|
||||
LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L)
|
||||
val size = entries.sumOf { it.length }
|
||||
val seed = row?.takeIf { it.shuffleEnabled && size > 0L && it.shuffleSize == size }?.shuffleSeed
|
||||
LoadedSnapshot(entries, upNext, seed, row?.upNextAnchor ?: 0L)
|
||||
}
|
||||
}
|
||||
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version)
|
||||
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleSeed, loaded.anchor, version)
|
||||
}
|
||||
|
||||
suspend fun modes(): QueueModes {
|
||||
@@ -242,24 +241,22 @@ class QueueRepository(
|
||||
|
||||
suspend fun setShuffle(
|
||||
enabled: Boolean,
|
||||
order: LongArray?,
|
||||
seed: Long,
|
||||
) {
|
||||
val seed = if (enabled && order != null) System.nanoTime() else 0L
|
||||
val size = if (enabled) contextSize() else 0L
|
||||
cursorMutex.withLock {
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.immediateTransaction {
|
||||
dao.clearShuffleOrder()
|
||||
if (enabled && order != null) {
|
||||
dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) })
|
||||
}
|
||||
dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed))
|
||||
}
|
||||
}
|
||||
dao.setCursor(
|
||||
cursorRow().copy(
|
||||
shuffleEnabled = enabled,
|
||||
shuffleSeed = if (enabled) seed else 0L,
|
||||
shuffleSize = size,
|
||||
),
|
||||
)
|
||||
}
|
||||
cachedShuffleSeed = 0
|
||||
cachedShuffleOrder = null
|
||||
}
|
||||
|
||||
private suspend fun setShuffleSize(size: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(shuffleSize = size)) }
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
cachedFlatIds = null
|
||||
lengthCache.clear()
|
||||
@@ -268,18 +265,6 @@ class QueueRepository(
|
||||
|
||||
suspend fun setRepeat(mode: Int) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(repeatMode = mode)) }
|
||||
|
||||
private suspend fun loadShuffleOrder(
|
||||
seed: Long,
|
||||
size: Long,
|
||||
): LongArray? {
|
||||
val cached = cachedShuffleOrder
|
||||
if (seed == cachedShuffleSeed && cached != null && cached.size.toLong() == size) return cached
|
||||
val loaded = dao.shuffleOrder().map { it.flatPosition }.toLongArray()
|
||||
cachedShuffleSeed = seed
|
||||
cachedShuffleOrder = loaded.takeIf { it.size.toLong() == size }
|
||||
return cachedShuffleOrder
|
||||
}
|
||||
|
||||
suspend fun itemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
@@ -303,9 +288,7 @@ class QueueRepository(
|
||||
): Long? {
|
||||
val flat = flatIds(snapshot).indexOf(songId)
|
||||
if (flat < 0) return null
|
||||
val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong()
|
||||
if (play < 0) return null
|
||||
return snapshot.combined(play)
|
||||
return snapshot.combined(snapshot.playContext(flat.toLong()))
|
||||
}
|
||||
|
||||
suspend fun range(
|
||||
@@ -407,18 +390,18 @@ class QueueRepository(
|
||||
return snapshot()
|
||||
}
|
||||
val play = snapshot.contextPlay(position)
|
||||
val order = snapshot.shuffleOrder
|
||||
if (order == null) {
|
||||
if (snapshot.shuffled) {
|
||||
// The permutation is derived from (seed, size), so removing a track re-derives it at
|
||||
// the smaller size; keep the same seed and store the new size so the queue stays
|
||||
// shuffled. There is no stable "next" to preserve here.
|
||||
val flat = snapshot.flatContext(play) ?: return snapshot
|
||||
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
|
||||
adjustAnchorOnRemove(flat)
|
||||
setShuffleSize(contextSize())
|
||||
} else {
|
||||
val removed = compact(removeEntry(snapshot.entries, play))
|
||||
write(removed.map { it.entry })
|
||||
adjustAnchorOnRemove(play)
|
||||
} else {
|
||||
val flat = order.getOrNull(play.toInt()) ?: return snapshot
|
||||
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
|
||||
val reordered = order.toMutableList().apply { removeAt(play.toInt()) }
|
||||
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
|
||||
setShuffle(true, reordered.toLongArray())
|
||||
adjustAnchorOnRemove(flat)
|
||||
}
|
||||
return snapshot()
|
||||
}
|
||||
@@ -466,16 +449,12 @@ class QueueRepository(
|
||||
return true
|
||||
}
|
||||
if (fromUpNext != toUpNext) return false
|
||||
// Reordering the context while shuffled would have to make the derived order match the
|
||||
// drop, which needs materialising the order again. The queue view disables context drags
|
||||
// while shuffled; manual ordering goes through the up-next block instead.
|
||||
if (snapshot.shuffled) return false
|
||||
val playFrom = snapshot.contextPlay(from)
|
||||
val playTo = snapshot.contextPlay(to)
|
||||
val order = snapshot.shuffleOrder
|
||||
if (order != null) {
|
||||
val reordered = order.toMutableList()
|
||||
val moved = reordered.removeAt(playFrom.toInt())
|
||||
reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
|
||||
setShuffle(true, reordered.toLongArray())
|
||||
return true
|
||||
}
|
||||
val located = snapshot.locateContext(playFrom) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val removed = removeEntry(snapshot.entries, playFrom)
|
||||
@@ -503,6 +482,8 @@ class QueueRepository(
|
||||
|
||||
private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0)
|
||||
|
||||
private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() }
|
||||
|
||||
private suspend fun write(entries: List<QueueEntry>) {
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.immediateTransaction {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
/**
|
||||
* A deterministic, seekable permutation of `[0, size)`: `toFlat(seed, size, s)` is the track played at
|
||||
* shuffled position `s` and `toSequence` is its inverse. Nothing per track is stored; the order is derived
|
||||
* from the seed and the queue length.
|
||||
*
|
||||
* The permutation is a balanced four-round Feistel network over the smallest power of two domain that
|
||||
* covers `size`, with `[size, domain)` skipped by cycle walking. A Feistel round is always a bijection,
|
||||
* so the only requirement is that the two halves have equal width; cycle walking keeps it a bijection on
|
||||
* `[0, size)`. The round function is a SplitMix64 mix of the seed, the half value and the round index.
|
||||
*/
|
||||
object Shuffle {
|
||||
private const val ROUNDS = 4
|
||||
private const val MAX_SIZE = 1L shl 40
|
||||
|
||||
private val GOLDEN = 0x9E3779B97F4A7C15uL.toLong()
|
||||
private val MIX_A = 0xBF58476D1CE4E5B9uL.toLong()
|
||||
private val MIX_B = 0x94D049BB133111EBuL.toLong()
|
||||
|
||||
fun toFlat(
|
||||
seed: Long,
|
||||
size: Long,
|
||||
sequence: Long,
|
||||
): Long {
|
||||
require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" }
|
||||
if (size <= 1L) return 0L
|
||||
if (sequence !in 0 until size) return sequence
|
||||
var value = encrypt(seed, sequence, halfBits(size))
|
||||
while (value >= size) value = encrypt(seed, value, halfBits(size))
|
||||
return value
|
||||
}
|
||||
|
||||
fun toSequence(
|
||||
seed: Long,
|
||||
size: Long,
|
||||
flat: Long,
|
||||
): Long {
|
||||
require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" }
|
||||
if (size <= 1L) return 0L
|
||||
if (flat !in 0 until size) return flat
|
||||
var value = decrypt(seed, flat, halfBits(size))
|
||||
while (value >= size) value = decrypt(seed, value, halfBits(size))
|
||||
return value
|
||||
}
|
||||
|
||||
private fun halfBits(size: Long): Int {
|
||||
if (size <= 1L) return 1
|
||||
val bits = 64 - java.lang.Long.numberOfLeadingZeros(size - 1)
|
||||
return (bits + 1) / 2
|
||||
}
|
||||
|
||||
private fun encrypt(
|
||||
seed: Long,
|
||||
value: Long,
|
||||
halfBits: Int,
|
||||
): Long {
|
||||
val mask = (1L shl halfBits) - 1
|
||||
var left = value ushr halfBits
|
||||
var right = value and mask
|
||||
for (round in 0 until ROUNDS) {
|
||||
val nextLeft = right
|
||||
val nextRight = left xor (mix(seed, right, round) and mask)
|
||||
left = nextLeft
|
||||
right = nextRight
|
||||
}
|
||||
return (left shl halfBits) or right
|
||||
}
|
||||
|
||||
private fun decrypt(
|
||||
seed: Long,
|
||||
value: Long,
|
||||
halfBits: Int,
|
||||
): Long {
|
||||
val mask = (1L shl halfBits) - 1
|
||||
var left = value ushr halfBits
|
||||
var right = value and mask
|
||||
for (round in ROUNDS - 1 downTo 0) {
|
||||
val previousLeft = right xor (mix(seed, left, round) and mask)
|
||||
val previousRight = left
|
||||
left = previousLeft
|
||||
right = previousRight
|
||||
}
|
||||
return (left shl halfBits) or right
|
||||
}
|
||||
|
||||
private fun mix(
|
||||
seed: Long,
|
||||
value: Long,
|
||||
round: Int,
|
||||
): Long {
|
||||
var z = seed + value * GOLDEN + round * MIX_A
|
||||
z = (z xor (z ushr 30)) * MIX_A
|
||||
z = (z xor (z ushr 27)) * MIX_B
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
import com.subtracks.data.repo.Shuffle
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -21,7 +22,6 @@ import kotlinx.coroutines.flow.drop
|
||||
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(
|
||||
@@ -59,7 +59,7 @@ private sealed interface QueueUndo {
|
||||
val entries: List<QueueEntry>
|
||||
val cursor: Long
|
||||
val current: Long?
|
||||
val shuffleOrder: LongArray?
|
||||
val shuffleSeed: Long?
|
||||
val upNext: List<QueueEntry>
|
||||
val upNextAnchor: Long
|
||||
}
|
||||
@@ -70,7 +70,7 @@ private data class RemovedUndo(
|
||||
override val current: Long,
|
||||
val position: Long,
|
||||
val item: QueueItem?,
|
||||
override val shuffleOrder: LongArray?,
|
||||
override val shuffleSeed: Long?,
|
||||
override val upNext: List<QueueEntry>,
|
||||
override val upNextAnchor: Long,
|
||||
) : QueueUndo
|
||||
@@ -81,7 +81,7 @@ private data class MovedUndo(
|
||||
override val current: Long,
|
||||
val from: Long,
|
||||
val to: Long,
|
||||
override val shuffleOrder: LongArray?,
|
||||
override val shuffleSeed: Long?,
|
||||
override val upNext: List<QueueEntry>,
|
||||
override val upNextAnchor: Long,
|
||||
) : QueueUndo
|
||||
@@ -89,7 +89,7 @@ private data class MovedUndo(
|
||||
private data class ReloadUndo(
|
||||
override val entries: List<QueueEntry>,
|
||||
override val cursor: Long,
|
||||
override val shuffleOrder: LongArray?,
|
||||
override val shuffleSeed: Long?,
|
||||
override val upNext: List<QueueEntry>,
|
||||
override val upNextAnchor: Long,
|
||||
) : QueueUndo {
|
||||
@@ -253,14 +253,15 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
val start = randomIndex(snap.contextSize, avoid)
|
||||
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) }
|
||||
queueRepository.setShuffle(true, order)
|
||||
val seed = Random.nextLong()
|
||||
val position = Shuffle.toSequence(seed, snap.contextSize, start)
|
||||
queueRepository.setShuffle(true, seed)
|
||||
shuffleEnabled = true
|
||||
queueSourceId = entry.sourceId
|
||||
queueRepository.setCursor(0)
|
||||
queueRepository.setCursor(position)
|
||||
snap = queueRepository.snapshot()
|
||||
this@PlaybackController.snapshot = snap
|
||||
loadWindow(0, autoplay = true)
|
||||
loadWindow(position, autoplay = true)
|
||||
}
|
||||
|
||||
private fun randomIndex(
|
||||
@@ -314,7 +315,7 @@ class PlaybackController(
|
||||
if (snapshot.size == 1L) {
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
stopLocked()
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
return@withLock
|
||||
}
|
||||
val current = currentPosition() ?: return@withLock
|
||||
@@ -327,24 +328,40 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
if (updated.size == 0L) {
|
||||
stopLocked()
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
return@withLock
|
||||
}
|
||||
val currentId = player.currentItem?.id
|
||||
val fallback =
|
||||
if (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))
|
||||
}
|
||||
} else {
|
||||
(if (position < current) current - 1 else current)
|
||||
}
|
||||
val target =
|
||||
(
|
||||
currentId?.let {
|
||||
if (wasUpNext) queueRepository.combinedIndexOf(updated, it) else queueRepository.combinedContextIndexOf(updated, it)
|
||||
} ?: (if (position < current) current - 1 else current)
|
||||
} ?: fallback
|
||||
).coerceIn(0, updated.size - 1)
|
||||
lastPosition = target
|
||||
lastEdit =
|
||||
if (removed == null) {
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
} else {
|
||||
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
}
|
||||
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) {
|
||||
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) {
|
||||
queueRepository.setCursor(target)
|
||||
if (position != current) {
|
||||
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
|
||||
@@ -410,9 +427,9 @@ class PlaybackController(
|
||||
val toInWindow = to in windowStart..windowEnd
|
||||
lastEdit =
|
||||
if (fromInWindow == toInWindow) {
|
||||
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
} else {
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
|
||||
ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor)
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
@@ -452,8 +469,8 @@ class PlaybackController(
|
||||
val player = player ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
val positionMs = player.currentPositionMs
|
||||
queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor)
|
||||
shuffleEnabled = undo.shuffleOrder != null
|
||||
queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor)
|
||||
shuffleEnabled = undo.shuffleSeed != null
|
||||
val restored = readSnapshot()
|
||||
this.snapshot = restored
|
||||
lastEdit = null
|
||||
@@ -702,11 +719,11 @@ 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) {
|
||||
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) }
|
||||
queueRepository.setShuffle(true, order)
|
||||
queueRepository.setShuffle(true, seed)
|
||||
} else {
|
||||
queueRepository.setShuffle(false, null)
|
||||
queueRepository.setShuffle(false, 0L)
|
||||
}
|
||||
shuffleEnabled = enabling
|
||||
val reordered = readSnapshot()
|
||||
@@ -714,7 +731,7 @@ class PlaybackController(
|
||||
val position =
|
||||
when {
|
||||
wasUpNext -> reordered.anchorPlay + 1 + blockIndex
|
||||
enabling -> reordered.combined(0)
|
||||
enabling -> reordered.combined(Shuffle.toSequence(seed, reordered.contextSize, flat))
|
||||
else -> reordered.combined(flat)
|
||||
}
|
||||
val anchor = queueRepository.reanchorFor(reordered, position)
|
||||
@@ -784,9 +801,9 @@ class PlaybackController(
|
||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||
val position =
|
||||
if (shuffleEnabled) {
|
||||
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
|
||||
queueRepository.setShuffle(true, order)
|
||||
0L
|
||||
val seed = Random.nextLong()
|
||||
queueRepository.setShuffle(true, seed)
|
||||
Shuffle.toSequence(seed, snapshot.contextSize, start)
|
||||
} else {
|
||||
start
|
||||
}
|
||||
@@ -808,9 +825,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 order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
|
||||
queueRepository.setShuffle(true, order)
|
||||
queueRepository.setCursor(0)
|
||||
val seed = Random.nextLong()
|
||||
queueRepository.setShuffle(true, seed)
|
||||
queueRepository.setCursor(Shuffle.toSequence(seed, snapshot.contextSize, start))
|
||||
snapshot = queueRepository.snapshot()
|
||||
} else if (!snapshot.shuffled) {
|
||||
shuffleEnabled = false
|
||||
@@ -1072,20 +1089,12 @@ class PlaybackController(
|
||||
if (repeatMode == RepeatMode.All) jumpTo(0)
|
||||
}
|
||||
|
||||
private fun shuffledOrder(
|
||||
size: Long,
|
||||
first: Long,
|
||||
): LongArray {
|
||||
val rest = (0 until size).filter { it != first }.shuffled()
|
||||
return (listOf(first) + rest).toLongArray()
|
||||
}
|
||||
|
||||
private fun Int.toRepeatMode(): RepeatMode = RepeatMode.entries.getOrElse(this) { RepeatMode.Off }
|
||||
|
||||
private suspend fun readSnapshot(): QueueSnapshot {
|
||||
val snapshot = queueRepository.snapshot()
|
||||
if (!snapshot.shuffled) {
|
||||
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, null)
|
||||
if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, 0L)
|
||||
shuffleEnabled = false
|
||||
}
|
||||
return snapshot
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
|
||||
|
||||
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
|
||||
|
||||
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation.
|
||||
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block.
|
||||
|
||||
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo
|
||||
Follow-up work for very large libraries, playlists and long sessions, grouped so each block can be one PR.
|
||||
|
||||
- **Library read path.** Index the sort keys used by album/artist/playlist browsing (a denormalized `albumArtist` on `albums` plus `NOCASE` indices) and replace `LIMIT 1 OFFSET` with keyset/cursor seeks in `QueueRepository`/`QueueDao`. Shared dependency for the read-side fixes; can split into "indices" then "keyset" if too large for one review.
|
||||
- **Shuffle.** Drop `shuffle_order` and the retained whole-order/whole-id arrays; derive the permutation from `(seed, size)` and resolve positions through the same positional lookup. Recommended after the read-path PR, since it shares `QueueRepository`.
|
||||
- **Shuffle.** Landed: the permutation is derived from `(seed, size)` (a seekable Feistel in `Shuffle.kt`) and positions resolve through the same positional lookup, so `shuffle_order` and the retained whole-order/whole-id arrays are gone. The context is read-only while shuffled; manual ordering goes through the up-next block.
|
||||
- **Sync pipeline.** Prune with a memory-bounded id set, remove `fetchRanks` and the write-only rank columns, fetch playlists/albums with bounded concurrency and skip unchanged ones, and raise/make configurable the page cap with a clear "library too large" failure. Independent of the read path.
|
||||
- **Queue view and edits.** Bound the queue view's loaded rows (or page over the queue ordinal) and scope its ViewModel to the overlay; compact adjacent same-ref ranges after an edit and cache resolved entry lengths. The view half is independent; the edit half shares `QueueRepository` with the read-path PR.
|
||||
- **Artwork.** Give `artwork_seeds` a `sourceId` with `ON DELETE CASCADE` and an LRU/TTL (or drop the table and rely on Coil's disk cache); prefetch the thumbnail, not the original, on now-playing transitions. Independent.
|
||||
|
||||
Reference in New Issue
Block a user