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
+32 -6
View File
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "1fd6e47da427e7d9faceae1bf0774007",
"identityHash": "409a9b0fb3a36dbda79d86b64c9656c3",
"entities": [
{
"tableName": "sources",
@@ -704,7 +704,7 @@
},
{
"tableName": "playback_cursor",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleOrder` TEXT, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -733,9 +733,11 @@
"defaultValue": "0"
},
{
"fieldPath": "shuffleOrder",
"columnName": "shuffleOrder",
"affinity": "TEXT"
"fieldPath": "shuffleSeed",
"columnName": "shuffleSeed",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
@@ -745,6 +747,30 @@
]
}
},
{
"tableName": "shuffle_order",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))",
"fields": [
{
"fieldPath": "sequence",
"columnName": "sequence",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "flatPosition",
"columnName": "flatPosition",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sequence"
]
}
},
{
"tableName": "artwork_seeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))",
@@ -777,7 +803,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1fd6e47da427e7d9faceae1bf0774007')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '409a9b0fb3a36dbda79d86b64c9656c3')"
]
}
}
@@ -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() {
@@ -689,7 +689,7 @@ class PlaybackControllerTest {
}
@Test
fun previousFromTheStartWrapsToTheLastTrack() {
fun previousFromTheStartRestartsWithoutRepeat() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
@@ -698,6 +698,27 @@ class PlaybackControllerTest {
?.id == "s1"
}
controller.previous()
assertTrue(handle.operations.contains("seekTo(0)"))
assertEquals(
"s1",
controller.state.value.item
?.id,
)
}
@Test
fun previousFromTheStartWrapsWithRepeatAll() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
controller.previous()
await {
controller.state.value.item