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, "formatVersion": 1,
"database": { "database": {
"version": 6, "version": 6,
"identityHash": "1fd6e47da427e7d9faceae1bf0774007", "identityHash": "409a9b0fb3a36dbda79d86b64c9656c3",
"entities": [ "entities": [
{ {
"tableName": "sources", "tableName": "sources",
@@ -704,7 +704,7 @@
}, },
{ {
"tableName": "playback_cursor", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -733,9 +733,11 @@
"defaultValue": "0" "defaultValue": "0"
}, },
{ {
"fieldPath": "shuffleOrder", "fieldPath": "shuffleSeed",
"columnName": "shuffleOrder", "columnName": "shuffleSeed",
"affinity": "TEXT" "affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
} }
], ],
"primaryKey": { "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", "tableName": "artwork_seeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))", "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": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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) { 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 `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 `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 androidx.room3.Upsert
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
private const val ALBUM_SONGS_SQL = private const val ALBUM_SONGS_SQL =
@@ -30,6 +31,15 @@ interface QueueDao {
@Query("SELECT * FROM queue_entries ORDER BY position, id") @Query("SELECT * FROM queue_entries ORDER BY position, id")
suspend fun entries(): List<QueueEntry> 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") @Query("SELECT * FROM playback_cursor WHERE id = 1")
suspend fun cursor(): PlaybackCursor? 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.QueueEntry
import com.subtracks.data.model.QueueKindConverter import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.SearchIndex import com.subtracks.data.model.SearchIndex
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
@@ -28,6 +29,7 @@ import com.subtracks.data.model.SubsonicSource
SearchIndex::class, SearchIndex::class,
QueueEntry::class, QueueEntry::class,
PlaybackCursor::class, PlaybackCursor::class,
ShuffleOrder::class,
ArtworkSeed::class, ArtworkSeed::class,
], ],
version = 6, version = 6,
@@ -210,7 +210,13 @@ data class PlaybackCursor(
val queuePosition: Long, val queuePosition: Long,
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false, @ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0, @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") @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.PlaybackCursor
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
const val QUEUE_CHUNK = 60 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 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>? { private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
var remaining = position var remaining = position
for (resolved in entries) { for (resolved in entries) {
@@ -60,6 +55,12 @@ class QueueRepository(
) { ) {
private val dao get() = db.queueDao() private val dao get() = db.queueDao()
@Volatile
private var cachedShuffleSeed = 0L
@Volatile
private var cachedShuffleOrder: LongArray? = null
fun playlistEntry( fun playlistEntry(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
@@ -82,13 +83,8 @@ class QueueRepository(
shuffleOrder: LongArray? = null, shuffleOrder: LongArray? = null,
) { ) {
write(entries) write(entries)
dao.setCursor( setShuffle(shuffleOrder != null, shuffleOrder)
cursorRow().copy( dao.setCursor(cursorRow().copy(queuePosition = 0))
queuePosition = 0,
shuffleEnabled = shuffleOrder != null,
shuffleOrder = shuffleOrder?.joinToString(","),
),
)
} }
suspend fun snapshot(): QueueSnapshot = suspend fun snapshot(): QueueSnapshot =
@@ -97,9 +93,10 @@ class QueueRepository(
val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) } val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }
val row = dao.cursor() val row = dao.cursor()
val order = val order =
row?.shuffleOrder?.parseOrder()?.takeIf { if (row?.shuffleEnabled == true) {
row.shuffleEnabled && loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length })
it.size.toLong() == entries.sumOf { e -> e.length } } else {
null
} }
QueueSnapshot(entries, order) QueueSnapshot(entries, order)
} }
@@ -113,10 +110,31 @@ class QueueRepository(
suspend fun setShuffle( suspend fun setShuffle(
enabled: Boolean, enabled: Boolean,
order: LongArray?, 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)) 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( suspend fun itemAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
@@ -307,10 +325,3 @@ class QueueRepository(
return result 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 val snapshot = snapshot ?: return
if (snapshot.size == 0L) return if (snapshot.size == 0L) return
val current = currentPosition() ?: 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() { fun cycleRepeat() {
@@ -689,7 +689,7 @@ class PlaybackControllerTest {
} }
@Test @Test
fun previousFromTheStartWrapsToTheLastTrack() { fun previousFromTheStartRestartsWithoutRepeat() {
seedAlbum(3, sourceId = 1) seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0) controller.playAlbum(1, "al1", 0)
@@ -698,6 +698,27 @@ class PlaybackControllerTest {
?.id == "s1" ?.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() controller.previous()
await { await {
controller.state.value.item controller.state.value.item