diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json index 84a8d1c2..d699504b 100644 --- a/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index a7f625c4..d87734f4 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -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`))", + ) } } diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 3c5a986d..9d68f894 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -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 + @Query("SELECT * FROM shuffle_order ORDER BY sequence") + suspend fun shuffleOrder(): List + + @Query("DELETE FROM shuffle_order") + suspend fun clearShuffleOrder() + + @Insert + suspend fun insertShuffleOrder(entries: List) + @Query("SELECT * FROM playback_cursor WHERE id = 1") suspend fun cursor(): PlaybackCursor? diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index ac73544e..424c6810 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 8f9b4720..2523fe76 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -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") diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 238ce43a..5c3a9e95 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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? { 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() } - } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 528c1cbf..03009b6b 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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() { diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 829e2c5d..cd679bf1 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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