store the shuffle order in a table with a cache; keep previous at the start unless repeating

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent b5799d7202
commit 8bd07e4332
8 changed files with 127 additions and 34 deletions
@@ -57,7 +57,11 @@ val MIGRATION_5_6 =
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleEnabled` INTEGER NOT NULL DEFAULT 0")
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `repeatMode` INTEGER NOT NULL DEFAULT 0")
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleOrder` TEXT")
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleSeed` INTEGER NOT NULL DEFAULT 0")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `shuffle_order` (`sequence` INTEGER NOT NULL, " +
"`flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))",
)
}
}
@@ -6,6 +6,7 @@ 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
private const val ALBUM_SONGS_SQL =
@@ -30,6 +31,15 @@ interface QueueDao {
@Query("SELECT * FROM queue_entries ORDER BY position, id")
suspend fun entries(): List<QueueEntry>
@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,6 +12,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.SearchIndex
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
@@ -28,6 +29,7 @@ import com.subtracks.data.model.SubsonicSource
SearchIndex::class,
QueueEntry::class,
PlaybackCursor::class,
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 6,
@@ -210,7 +210,13 @@ data class PlaybackCursor(
val queuePosition: Long,
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
val shuffleOrder: String? = null,
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
)
@Entity(tableName = "shuffle_order")
data class ShuffleOrder(
@PrimaryKey val sequence: Long,
val flatPosition: Long,
)
@Entity(tableName = "artwork_seeds")
@@ -8,6 +8,7 @@ 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
const val QUEUE_CHUNK = 60
@@ -29,12 +30,6 @@ data class QueueSnapshot(
fun flatPosition(sequence: Long): Long? = if (shuffleOrder == null) sequence else shuffleOrder.getOrNull(sequence.toInt())
fun sequencePosition(flat: Long): Long {
val order = shuffleOrder ?: return flat
val index = order.indexOfFirst { it == flat }
return if (index >= 0) index.toLong() else flat
}
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
var remaining = position
for (resolved in entries) {
@@ -60,6 +55,12 @@ class QueueRepository(
) {
private val dao get() = db.queueDao()
@Volatile
private var cachedShuffleSeed = 0L
@Volatile
private var cachedShuffleOrder: LongArray? = null
fun playlistEntry(
sourceId: Long,
playlistId: String,
@@ -82,13 +83,8 @@ class QueueRepository(
shuffleOrder: LongArray? = null,
) {
write(entries)
dao.setCursor(
cursorRow().copy(
queuePosition = 0,
shuffleEnabled = shuffleOrder != null,
shuffleOrder = shuffleOrder?.joinToString(","),
),
)
setShuffle(shuffleOrder != null, shuffleOrder)
dao.setCursor(cursorRow().copy(queuePosition = 0))
}
suspend fun snapshot(): QueueSnapshot =
@@ -97,9 +93,10 @@ class QueueRepository(
val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }
val row = dao.cursor()
val order =
row?.shuffleOrder?.parseOrder()?.takeIf {
row.shuffleEnabled &&
it.size.toLong() == entries.sumOf { e -> e.length }
if (row?.shuffleEnabled == true) {
loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length })
} else {
null
}
QueueSnapshot(entries, order)
}
@@ -113,10 +110,31 @@ class QueueRepository(
suspend fun setShuffle(
enabled: Boolean,
order: LongArray?,
) = dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleOrder = order?.joinToString(",")))
) {
val seed = if (enabled && order != null) System.nanoTime() else 0L
dao.clearShuffleOrder()
if (enabled && order != null) {
dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) })
}
dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed))
cachedShuffleSeed = 0
cachedShuffleOrder = null
}
suspend fun setRepeat(mode: Int) = 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,
@@ -307,10 +325,3 @@ class QueueRepository(
return result
}
}
private fun String.parseOrder(): LongArray? =
if (isEmpty()) {
null
} else {
split(',').mapNotNull { it.toLongOrNull() }.toLongArray().takeIf { it.isNotEmpty() }
}
@@ -403,7 +403,20 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
jumpTo(if (current > 0) current - 1 else snapshot.size - 1)
when {
current > 0 -> {
jumpTo(current - 1)
}
repeatMode == RepeatMode.All -> {
jumpTo(snapshot.size - 1)
}
else -> {
player.seekTo(0)
refresh()
}
}
}
fun cycleRepeat() {