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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user