add repeat and shuffle modes and fix duplicate track ordering

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 5a1915d06b
commit a755de5356
13 changed files with 1227 additions and 46 deletions
+783
View File
@@ -0,0 +1,783 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "1fd6e47da427e7d9faceae1bf0774007",
"entities": [
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "subsonic_sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useTokenAuth",
"columnName": "useTokenAuth",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId"
]
},
"indices": [
{
"name": "index_subsonic_sources_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_artists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumArtist",
"columnName": "albumArtist",
"affinity": "TEXT"
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "frequentRank",
"columnName": "frequentRank",
"affinity": "INTEGER"
},
{
"fieldPath": "recentRank",
"columnName": "recentRank",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_albums_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_albums_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT"
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT"
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_playlists_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlist_songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"playlistId",
"position"
]
},
"indices": [
{
"name": "index_playlist_songs_sourceId_playlistId",
"unique": false,
"columnNames": [
"sourceId",
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)"
},
{
"name": "index_playlist_songs_sourceId_songId",
"unique": false,
"columnNames": [
"sourceId",
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "songs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER"
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER"
},
{
"fieldPath": "disc",
"columnName": "disc",
"affinity": "INTEGER"
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"sourceId",
"id"
]
},
"indices": [
{
"name": "index_songs_sourceId_albumId",
"unique": false,
"columnNames": [
"sourceId",
"albumId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)"
},
{
"name": "index_songs_sourceId_artistId",
"unique": false,
"columnNames": [
"sourceId",
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "search_index",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`sourceId` UNINDEXED, `type` UNINDEXED, `itemId` UNINDEXED, `title`, tokenize=`trigram`)",
"fields": [
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowid"
]
},
"ftsVersion": "FTS5",
"ftsOptions": {
"tokenizer": "trigram",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [
"sourceId",
"type",
"itemId"
],
"prefixSizes": [],
"preferredOrder": "ASC",
"contentRowId": "",
"columnSize": true,
"detail": "FULL"
},
"contentSyncTriggers": []
},
{
"tableName": "queue_entries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "refId",
"columnName": "refId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rangeStart",
"columnName": "rangeStart",
"affinity": "INTEGER"
},
{
"fieldPath": "rangeEnd",
"columnName": "rangeEnd",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_queue_entries_sourceId",
"unique": false,
"columnNames": [
"sourceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)"
},
{
"name": "index_queue_entries_position",
"unique": false,
"columnNames": [
"position"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sourceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"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`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "queuePosition",
"columnName": "queuePosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shuffleEnabled",
"columnName": "shuffleEnabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "repeatMode",
"columnName": "repeatMode",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "shuffleOrder",
"columnName": "shuffleOrder",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "artwork_seeds",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, PRIMARY KEY(`cacheKey`))",
"fields": [
{
"fieldPath": "cacheKey",
"columnName": "cacheKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "primary",
"columnName": "primary",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secondary",
"columnName": "secondary",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"cacheKey"
]
}
}
],
"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')"
]
}
}
@@ -140,7 +140,7 @@ interface LibraryDao {
playlistId: String,
): Flow<Playlist?>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track")
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc, track, id")
fun songsByAlbum(
sourceId: Long,
albumId: String,
@@ -52,4 +52,13 @@ val MIGRATION_4_5 =
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
val MIGRATION_5_6 =
object : 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")
}
}
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
@@ -30,7 +30,7 @@ import com.subtracks.data.model.SubsonicSource
PlaybackCursor::class,
ArtworkSeed::class,
],
version = 5,
version = 6,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -208,6 +208,9 @@ data class QueueEntry(
data class PlaybackCursor(
@PrimaryKey val id: Long = 1,
val queuePosition: Long,
@ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false,
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
val shuffleOrder: String? = null,
)
@Entity(tableName = "artwork_seeds")
@@ -19,10 +19,23 @@ data class ResolvedQueueEntry(
data class QueueSnapshot(
val entries: List<ResolvedQueueEntry>,
val shuffleOrder: LongArray? = null,
) {
val size: Long = entries.sumOf { it.length }
fun locate(position: Long): Pair<QueueEntry, Long>? {
val shuffled: Boolean get() = shuffleOrder != null
fun locate(position: Long): Pair<QueueEntry, Long>? = locateFlat(flatPosition(position) ?: return null)
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) {
if (remaining < resolved.length) return resolved.entry to remaining
@@ -32,6 +45,11 @@ data class QueueSnapshot(
}
}
data class QueueModes(
val shuffle: Boolean,
val repeat: Int,
)
data class QueueWindowItem(
val position: Long,
val item: SongListItem,
@@ -59,18 +77,46 @@ class QueueRepository(
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun replace(entries: List<QueueEntry>) {
suspend fun replace(
entries: List<QueueEntry>,
shuffleOrder: LongArray? = null,
) {
write(entries)
dao.setCursor(PlaybackCursor(queuePosition = 0))
dao.setCursor(
cursorRow().copy(
queuePosition = 0,
shuffleEnabled = shuffleOrder != null,
shuffleOrder = shuffleOrder?.joinToString(","),
),
)
}
suspend fun snapshot(): QueueSnapshot =
db.useReaderConnection { transactor ->
transactor.deferredTransaction {
QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
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 }
}
QueueSnapshot(entries, order)
}
}
suspend fun modes(): QueueModes {
val row = dao.cursor() ?: return QueueModes(shuffle = false, repeat = 0)
return QueueModes(row.shuffleEnabled, row.repeatMode)
}
suspend fun setShuffle(
enabled: Boolean,
order: LongArray?,
) = dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleOrder = order?.joinToString(",")))
suspend fun setRepeat(mode: Int) = dao.setCursor(cursorRow().copy(repeatMode = mode))
suspend fun itemAt(
snapshot: QueueSnapshot,
position: Long,
@@ -84,6 +130,14 @@ class QueueRepository(
first: Long,
last: Long,
): List<QueueWindowItem> {
if (snapshot.shuffled) {
val items = mutableListOf<QueueWindowItem>()
for (position in first..last) {
val (entry, offset) = snapshot.locate(position) ?: continue
rows(entry, entry.offset + offset, 1).firstOrNull()?.let { items += QueueWindowItem(position, it) }
}
return items
}
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.entries) {
@@ -112,16 +166,36 @@ class QueueRepository(
suspend fun removeAt(
snapshot: QueueSnapshot,
position: Long,
) = write(removeEntry(snapshot.entries, position).map { it.entry })
): QueueSnapshot {
val order = snapshot.shuffleOrder
if (order == null) {
write(removeEntry(snapshot.entries, position).map { it.entry })
return snapshot()
}
val flat = order.getOrNull(position.toInt()) ?: return snapshot
write(removeEntry(snapshot.entries, flat).map { it.entry })
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
setShuffle(true, reordered.toLongArray())
return snapshot()
}
suspend fun move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean {
val before = snapshot()
val located = before.locate(from) ?: return false
val song = itemAt(before, from) ?: return false
val removed = removeEntry(before.entries, from)
val order = snapshot.shuffleOrder
if (order != null) {
val reordered = order.toMutableList()
val moved = reordered.removeAt(from.toInt())
reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
setShuffle(true, reordered.toLongArray())
return true
}
val located = snapshot.locate(from) ?: return false
val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, from)
val moved = songEntry(located.first.sourceId, song.song.id)
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
return true
@@ -129,7 +203,9 @@ class QueueRepository(
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
suspend fun setCursor(position: Long) = dao.setCursor(cursorRow().copy(queuePosition = position))
private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0)
private suspend fun write(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
@@ -231,3 +307,10 @@ class QueueRepository(
return result
}
}
private fun String.parseOrder(): LongArray? =
if (isEmpty()) {
null
} else {
split(',').mapNotNull { it.toLongOrNull() }.toLongArray().takeIf { it.isNotEmpty() }
}
@@ -86,6 +86,10 @@ class Media3PlayerHandle(
controller.seekTo(positionMs)
}
override fun setRepeatOne(enabled: Boolean) {
controller.repeatMode = if (enabled) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
override fun addListener(listener: PlayerHandle.Listener) {
controller.addListener(
object : Player.Listener {
@@ -33,6 +33,8 @@ data class QueueContext(
val refId: String,
)
enum class RepeatMode { Off, All, One }
data class PlaybackState(
val item: QueueItem? = null,
val context: QueueContext? = null,
@@ -44,12 +46,15 @@ data class PlaybackState(
val durationMs: Long = 0,
val hasNext: Boolean = false,
val hasPrevious: Boolean = false,
val shuffle: Boolean = false,
val repeat: RepeatMode = RepeatMode.Off,
)
private sealed interface QueueUndo {
val entries: List<QueueEntry>
val cursor: Long
val current: Long?
val shuffleOrder: LongArray?
}
private data class RemovedUndo(
@@ -58,6 +63,7 @@ private data class RemovedUndo(
override val current: Long,
val position: Long,
val item: QueueItem?,
override val shuffleOrder: LongArray?,
) : QueueUndo
private data class MovedUndo(
@@ -66,11 +72,13 @@ private data class MovedUndo(
override val current: Long,
val from: Long,
val to: Long,
override val shuffleOrder: LongArray?,
) : QueueUndo
private data class ReloadUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
override val shuffleOrder: LongArray?,
) : QueueUndo {
override val current: Long? = null
}
@@ -102,6 +110,9 @@ class PlaybackController(
private var windowEnd = -1L
private var updating = false
private var lastEdit: QueueUndo? = null
private var shuffleEnabled = false
private var repeatMode = RepeatMode.Off
private var endedHandled = false
init {
scope.launch {
@@ -186,7 +197,7 @@ class PlaybackController(
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
stopLocked()
lastEdit = ReloadUndo(entries, cursor)
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
return@withLock
}
val current = currentPosition() ?: return@withLock
@@ -198,9 +209,9 @@ class PlaybackController(
windowJob?.cancel()
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor)
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
} else {
RemovedUndo(entries, cursor, target, position, removed)
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
}
when {
position == current -> {
@@ -240,7 +251,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
if (!queueRepository.move(from, to)) return@withLock
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = queueRepository.snapshot()
val target = movedCursor(current, from, to)
windowJob?.cancel()
@@ -248,9 +259,9 @@ class PlaybackController(
val toInWindow = to in windowStart..windowEnd
lastEdit =
if (fromInWindow == toInWindow) {
MovedUndo(entries, cursor, target, from, to)
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
} else {
ReloadUndo(entries, cursor)
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
}
when {
fromInWindow && toInWindow -> {
@@ -290,7 +301,8 @@ class PlaybackController(
val player = player ?: return@withLock
val playing = player.playWhenReady
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries)
queueRepository.replace(undo.entries, undo.shuffleOrder)
shuffleEnabled = undo.shuffleOrder != null
val restored = queueRepository.snapshot()
this.snapshot = restored
lastEdit = null
@@ -371,9 +383,71 @@ class PlaybackController(
if (player.playWhenReady) player.pause() else player.play()
}
fun next() = skip(1)
fun next() {
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
}
}
fun previous() = skip(-1)
fun previous() {
val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
player.seekTo(0)
refresh()
return
}
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
jumpTo(if (current > 0) current - 1 else snapshot.size - 1)
}
fun cycleRepeat() {
val next =
when (repeatMode) {
RepeatMode.Off -> RepeatMode.All
RepeatMode.All -> RepeatMode.One
RepeatMode.One -> RepeatMode.Off
}
repeatMode = next
player?.setRepeatOne(next == RepeatMode.One)
scope.launch { queueRepository.setRepeat(next.ordinal) }
refresh()
}
fun toggleShuffle() {
scope.launch {
startLock.withLock {
val snapshot = queueRepository.snapshot()
this@PlaybackController.snapshot = snapshot
if (snapshot.size == 0L) return@withLock
val player = player ?: return@withLock
val current = currentPosition() ?: return@withLock
val positionMs = player.currentPositionMs
val playing = player.playWhenReady
val flat = snapshot.flatPosition(current) ?: return@withLock
if (shuffleEnabled) {
queueRepository.setShuffle(false, null)
shuffleEnabled = false
queueRepository.setCursor(flat)
this@PlaybackController.snapshot = queueRepository.snapshot()
loadWindow(flat, autoplay = playing, startPositionMs = positionMs)
} else {
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, flat))
shuffleEnabled = true
queueRepository.setCursor(0)
this@PlaybackController.snapshot = queueRepository.snapshot()
loadWindow(0, autoplay = playing, startPositionMs = positionMs)
}
endedHandled = false
refresh(if (shuffleEnabled) 0L else flat)
}
}
}
fun seekTo(positionMs: Long) {
player?.seekTo(positionMs)
@@ -413,6 +487,11 @@ class PlaybackController(
lastDurationMs = 0
lastError = null
lastEdit = null
endedHandled = false
val modes = queueRepository.modes()
shuffleEnabled = modes.shuffle
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
queueRepository.replace(entries)
val snapshot = queueRepository.snapshot()
this.snapshot = snapshot
@@ -421,7 +500,14 @@ class PlaybackController(
stopLocked()
return@withLock
}
val position = startPosition.coerceIn(0, snapshot.size - 1)
val start = startPosition.coerceIn(0, snapshot.size - 1)
val position =
if (shuffleEnabled) {
queueRepository.setShuffle(true, shuffledOrder(snapshot.size, start))
0L
} else {
start
}
queueRepository.setCursor(position)
loadWindow(position, autoplay = true)
}
@@ -431,6 +517,11 @@ class PlaybackController(
windowJob?.cancel()
windowStart = 0
windowEnd = -1
endedHandled = false
val modes = queueRepository.modes()
shuffleEnabled = modes.shuffle
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
val snapshot = queueRepository.snapshot()
this.snapshot = snapshot
val queueSourceId =
@@ -492,22 +583,24 @@ class PlaybackController(
refresh(position)
}
private fun skip(delta: Long) {
private fun jumpTo(target: Long) {
scope.launch {
val current = currentPosition() ?: return@launch
val snapshot = queueRepository.snapshot()
this@PlaybackController.snapshot = snapshot
if (snapshot.size == 0L) return@launch
val position = current.coerceIn(0, snapshot.size - 1)
val target = (position + delta).coerceIn(0, snapshot.size - 1)
if (target == position) return@launch
val current = currentPosition() ?: return@launch
val dest = target.coerceIn(0, snapshot.size - 1)
if (dest == current) return@launch
windowJob?.cancel()
queueRepository.setCursor(target)
shiftWindow(target)
queueRepository.setCursor(dest)
shiftWindow(dest)
val player = player ?: return@launch
val wasEnded = player.isEnded
player.ensurePrepared()
player.seekToIndex((target - windowStart).toInt())
refresh(target)
player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play()
endedHandled = false
refresh(dest)
}
}
@@ -588,11 +681,15 @@ class PlaybackController(
object : PlayerHandle.Listener {
override fun onTransition() {
lastError = null
endedHandled = false
if (updating) return
scope.launch { onPositionChanged() }
}
override fun onEvents() = refresh()
override fun onEvents() {
if (player?.isEnded == true) handleEnded()
refresh()
}
override fun onError(message: String) {
lastError = message
@@ -600,6 +697,22 @@ class PlaybackController(
}
}
private fun handleEnded() {
if (endedHandled) return
endedHandled = true
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 fun contextAt(position: Long?): QueueContext? {
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
return QueueContext(entry.kind, entry.refId)
@@ -626,7 +739,6 @@ class PlaybackController(
private fun refresh(position: Long? = currentPosition()) {
val player = player ?: return
updateBuffering()
val size = snapshot?.size ?: 0
lastDurationMs = player.durationMs.takeIf { it > 0 } ?: lastDurationMs
_state.value =
PlaybackState(
@@ -638,8 +750,10 @@ class PlaybackController(
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
positionMs = player.currentPositionMs,
durationMs = lastDurationMs,
hasNext = position != null && position < size - 1,
hasPrevious = position != null && position > 0,
hasNext = true,
hasPrevious = true,
shuffle = shuffleEnabled,
repeat = repeatMode,
)
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
}
@@ -670,5 +784,6 @@ class PlaybackController(
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L
const val RESTART_THRESHOLD_MS = 3_000L
}
}
@@ -52,6 +52,8 @@ interface PlayerHandle {
fun seekTo(positionMs: Long)
fun setRepeatOne(enabled: Boolean)
fun addListener(listener: Listener)
interface Listener {
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.PauseCircle
import androidx.compose.material.icons.rounded.PlayCircle
import androidx.compose.material.icons.rounded.Repeat
import androidx.compose.material.icons.rounded.RepeatOne
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious
import androidx.compose.material3.CircularProgressIndicator
@@ -57,6 +60,7 @@ import coil3.request.ImageRequest
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache
@@ -91,6 +95,8 @@ fun NowPlayingRoute(
onPlayPause = controller::togglePlayPause,
onNext = controller::next,
onPrevious = controller::previous,
onShuffle = controller::toggleShuffle,
onRepeat = controller::cycleRepeat,
onSeek = controller::seekTo,
modifier = modifier,
)
@@ -121,6 +127,8 @@ fun NowPlayingScreen(
onPlayPause: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {},
onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null,
modifier: Modifier = Modifier,
@@ -315,7 +323,8 @@ fun NowPlayingScreen(
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 56.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(onClick = onQueue, modifier = Modifier.size(40.dp)) {
Icon(
@@ -324,6 +333,41 @@ fun NowPlayingScreen(
modifier = Modifier.size(30.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onShuffle, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle",
tint =
if (state.shuffle) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(26.dp),
)
}
IconButton(onClick = onRepeat, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = if (state.repeat == RepeatMode.One) Icons.Rounded.RepeatOne else Icons.Rounded.Repeat,
contentDescription =
when (state.repeat) {
RepeatMode.Off -> "Repeat off"
RepeatMode.All -> "Repeat all"
RepeatMode.One -> "Repeat one"
},
tint =
if (state.repeat ==
RepeatMode.Off
) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onBackground
},
modifier = Modifier.size(26.dp),
)
}
}
}
}
}
@@ -285,7 +285,7 @@ class QueueRepositoryTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.move(from = 0, to = 2)
repository.move(repository.snapshot(), from = 0, to = 2)
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
}
@@ -296,7 +296,7 @@ class QueueRepositoryTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.move(from = 0, to = 3)
repository.move(repository.snapshot(), from = 0, to = 3)
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
}
@@ -307,8 +307,8 @@ class QueueRepositoryTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
assertFalse(repository.move(from = 5, to = 0))
assertTrue(repository.move(from = 0, to = 2))
assertFalse(repository.move(repository.snapshot(), from = 5, to = 0))
assertTrue(repository.move(repository.snapshot(), from = 0, to = 2))
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
}
@@ -18,6 +18,9 @@ class FakePlayerHandle : PlayerHandle {
@Volatile
var duration = 0L
@Volatile
var positionMs = 0L
private val listeners = CopyOnWriteArrayList<PlayerHandle.Listener>()
@Volatile
@@ -54,7 +57,7 @@ class FakePlayerHandle : PlayerHandle {
override val durationMs: Long get() = duration
override val currentPositionMs: Long get() = 0
override val currentPositionMs: Long get() = positionMs
override val currentItem: QueueItem? get() = items.getOrNull(index)
@@ -190,6 +193,10 @@ class FakePlayerHandle : PlayerHandle {
notifyEvents()
}
override fun setRepeatOne(enabled: Boolean) {
operations += "setRepeatOne($enabled)"
}
override fun addListener(listener: PlayerHandle.Listener) {
listeners += listener
}
@@ -169,7 +169,7 @@ class PlaybackControllerTest {
}
@Test
fun theLastTrackReportsNoNext() {
fun theLastTrackStillEnablesNext() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
@@ -178,7 +178,7 @@ class PlaybackControllerTest {
?.id == "s3"
}
assertFalse(controller.state.value.hasNext)
assertTrue(controller.state.value.hasNext)
assertTrue(controller.state.value.hasPrevious)
}
@@ -308,7 +308,7 @@ class PlaybackControllerTest {
assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context)
assertTrue(controller.state.value.isPlaying)
assertTrue(controller.state.value.hasNext)
assertFalse(controller.state.value.hasPrevious)
assertTrue(controller.state.value.hasPrevious)
}
@Test
@@ -371,7 +371,7 @@ class PlaybackControllerTest {
assertEquals(0L, controller.state.value.position)
assertTrue(handle.operations.any { it.startsWith("setWindow") })
assertFalse(controller.state.value.hasPrevious)
assertTrue(controller.state.value.hasPrevious)
}
@Test
@@ -595,6 +595,137 @@ class PlaybackControllerTest {
assertEquals(listOf("s1", "s2", "s3"), handle.items.map { it.id })
}
@Test
fun shuffleKeepsTheCurrentTrackAndRestoresTheOriginalOrder() {
seedAlbum(5, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.toggleShuffle()
await { controller.state.value.shuffle }
assertEquals(
"s3",
controller.state.value.item
?.id,
)
assertEquals(0L, controller.state.value.position)
assertEquals("s3", handle.items.first().id)
controller.toggleShuffle()
await { !controller.state.value.shuffle }
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
assertEquals(
"s3",
controller.state.value.item
?.id,
)
}
@Test
fun nextWrapsToTheStartWhenRepeating() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
controller.next()
await {
controller.state.value.item
?.id == "s1"
}
}
@Test
fun nextOnTheLastTrackDoesNothingWithoutRepeat() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.next()
Thread.sleep(100)
assertEquals(
"s3",
controller.state.value.item
?.id,
)
}
@Test
fun previousRestartsTheTrackWhenPastTheThreshold() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
handle.positionMs = 5_000L
controller.previous()
assertTrue(handle.operations.contains("seekTo(0)"))
assertEquals(
"s2",
controller.state.value.item
?.id,
)
}
@Test
fun previousFromTheStartWrapsToTheLastTrack() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.previous()
await {
controller.state.value.item
?.id == "s3"
}
}
@Test
fun repeatAllRestartsTheQueueWhenItEnds() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
handle.finish()
handle.emitEvents()
await {
controller.state.value.item
?.id == "s1"
}
}
private fun seedAlbum(
count: Int,
sourceId: Long,