diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json new file mode 100644 index 00000000..84a8d1c2 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/6.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 8d7927da..b727250b 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -140,7 +140,7 @@ interface LibraryDao { playlistId: String, ): Flow - @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, 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 8e9ed7d5..a7f625c4 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -52,4 +52,13 @@ val MIGRATION_4_5 = } } -val MIGRATIONS: Array = 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 = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) 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 d2c9bbc6..ac73544e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -30,7 +30,7 @@ import com.subtracks.data.model.SubsonicSource PlaybackCursor::class, ArtworkSeed::class, ], - version = 5, + version = 6, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) 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 e631426c..8f9b4720 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -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") 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 72277af8..238ce43a 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -19,10 +19,23 @@ data class ResolvedQueueEntry( data class QueueSnapshot( val entries: List, + val shuffleOrder: LongArray? = null, ) { val size: Long = entries.sumOf { it.length } - fun locate(position: Long): Pair? { + val shuffled: Boolean get() = shuffleOrder != null + + fun locate(position: Long): Pair? = 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? { 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) { + suspend fun replace( + entries: List, + 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 { + if (snapshot.shuffled) { + val items = mutableListOf() + 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() 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) { 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() } + } diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index ec56624f..6cdf7f5a 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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 { diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index a0c01a09..528c1cbf 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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 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, 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 } } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index 38986474..a3ef4df1 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -52,6 +52,8 @@ interface PlayerHandle { fun seekTo(positionMs: Long) + fun setRepeatOne(enabled: Boolean) + fun addListener(listener: Listener) interface Listener { diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index a2082d39..d99c267f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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), + ) + } + } } } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 3be83525..f3f271f7 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -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())) } diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index 4efc746a..6ead7aa9 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -18,6 +18,9 @@ class FakePlayerHandle : PlayerHandle { @Volatile var duration = 0L + @Volatile + var positionMs = 0L + private val listeners = CopyOnWriteArrayList() @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 } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 58abbdb5..829e2c5d 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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,