From 93742e858401e75ee866f6a3af629f6459546ca5 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:38:51 +0000 Subject: [PATCH] rework: derive the shuffle order from a seed on top of the up-next queue Replaces the per-track `shuffle_order` table and the retained whole-order arrays with `playback_cursor.shuffleSeed`/`shuffleSize` and a seekable Feistel permutation of `[0, size)` (`Shuffle.kt`). Positions map through `flatContext`/`playContext`; a snapshot is shuffled only while the stored size matches the context, so a stale size is ignored. Queue migration 16->17 adds `shuffleSize` and drops `shuffle_order`. Drops the "dragging a context row while shuffled reorders the canonical queue" behaviour: it cannot be made WYSIWYG without materialising the order. The context is playback-only while shuffled (the view already disables context drags) and `QueueRepository.move` no-ops for a context move while shuffled; manual ordering goes through the up-next block. Removing a context track while shuffled re-derives the permutation at the smaller size, keeping the same seed and the playhead on the current track, or on whatever now fills the removed track's slot when it was playing. --- .../17.json | 1112 +++++++++++++++++ .../com/subtracks/data/db/Migrations.kt | 9 + .../kotlin/com/subtracks/data/db/QueueDao.kt | 10 - .../subtracks/data/db/SubtracksDatabase.kt | 4 +- .../kotlin/com/subtracks/data/model/Models.kt | 7 +- .../subtracks/data/repo/QueueRepository.kt | 117 +- .../kotlin/com/subtracks/data/repo/Shuffle.kt | 97 ++ .../subtracks/playback/PlaybackController.kt | 85 +- .../data/repo/QueueRepositoryTest.kt | 101 +- .../com/subtracks/data/repo/ShuffleTest.kt | 113 ++ .../playback/PlaybackControllerTest.kt | 125 +- docs/architecture.md | 2 +- docs/implementation-plan.md | 2 +- 13 files changed, 1646 insertions(+), 138 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json create mode 100644 app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json new file mode 100644 index 00000000..69e777ff --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json @@ -0,0 +1,1112 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "f7666d92ab17450b28ffdc6f9a021af1", + "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 COLLATE NOCASE, `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`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + } + ], + "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 COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, 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 + } + ], + "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`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `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": "changed", + "columnName": "changed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "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`)" + }, + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_playlists_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_playlists_updated", + "unique": false, + "columnNames": [ + "sourceId", + "changed", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)" + } + ], + "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, `created` 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": "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" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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": "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": "up_next_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_up_next_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_up_next_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_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, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `shuffleSize` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, `upNextAnchor` INTEGER NOT NULL DEFAULT 0, 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": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSize", + "columnName": "shuffleSize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "upNextAnchor", + "columnName": "upNextAnchor", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `nameBusy` INTEGER, `sourceId` INTEGER NOT NULL, `storedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameBusy", + "columnName": "nameBusy", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storedAt", + "columnName": "storedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_artwork_seeds_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'f7666d92ab17450b28ffdc6f9a021af1')" + ] + } +} \ 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 fdab336e..e431af35 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -163,6 +163,14 @@ val MIGRATION_15_16 = } } +val MIGRATION_16_17 = + object : Migration(16, 17) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `shuffleSize` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("DROP TABLE IF EXISTS `shuffle_order`") + } + } + private suspend fun rebuildTable( connection: SQLiteConnection, table: String, @@ -252,4 +260,5 @@ val MIGRATIONS: Array = MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_16_17, ) 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 8a9f0518..aa1d5795 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -6,7 +6,6 @@ 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 import com.subtracks.data.model.UpNextEntry @@ -41,15 +40,6 @@ interface QueueDao { @Query("SELECT * FROM up_next_entries ORDER BY position, id") suspend fun upNextEntries(): 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 24f71f52..6f3ef79d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -12,7 +12,6 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKindConverter -import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.Song import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource @@ -31,10 +30,9 @@ import com.subtracks.data.model.UpNextEntry QueueEntry::class, UpNextEntry::class, PlaybackCursor::class, - ShuffleOrder::class, ArtworkSeed::class, ], - version = 16, + version = 17, 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 7d9f8035..587d6811 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -305,16 +305,11 @@ data class PlaybackCursor( @ColumnInfo(defaultValue = "0") val shuffleEnabled: Boolean = false, @ColumnInfo(defaultValue = "0") val repeatMode: Int = 0, @ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0, + @ColumnInfo(defaultValue = "0") val shuffleSize: Long = 0, @ColumnInfo(defaultValue = "0") val positionMs: Long = 0, @ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0, ) -@Entity(tableName = "shuffle_order") -data class ShuffleOrder( - @PrimaryKey val sequence: Long, - val flatPosition: Long, -) - @Entity( tableName = "artwork_seeds", foreignKeys = [ 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 fbb3cf4d..7ae286f5 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -8,7 +8,6 @@ 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 import com.subtracks.data.model.UpNextEntry import kotlinx.coroutines.flow.first @@ -33,7 +32,7 @@ private data class EntryLengthKey( private data class LoadedSnapshot( val entries: List, val upNext: List, - val shuffleOrder: LongArray?, + val shuffleSeed: Long?, val anchor: Long, ) @@ -45,7 +44,7 @@ data class ResolvedQueueEntry( data class QueueSnapshot( val entries: List, val upNext: List = emptyList(), - val shuffleOrder: LongArray? = null, + val shuffleSeed: Long? = null, val upNextAnchor: Long = 0, val version: Long = 0, ) { @@ -55,13 +54,14 @@ data class QueueSnapshot( val size: Long = contextSize + upNextSize - val shuffled: Boolean get() = shuffleOrder != null + val shuffled: Boolean get() = shuffleSeed != null val anchorPlay: Long = ( - when (val order = shuffleOrder) { - null -> upNextAnchor - else -> if (upNextAnchor < 0L) -1L else order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L + when { + shuffleSeed == null -> upNextAnchor + upNextAnchor < 0L -> -1L + else -> playContext(upNextAnchor) } ).coerceAtMost(contextSize - 1L) @@ -75,11 +75,15 @@ data class QueueSnapshot( fun combined(contextPlay: Long): Long = if (contextPlay <= anchorPlay) contextPlay else contextPlay + upNextSize - fun flatContext(contextPlay: Long): Long? = - when (val order = shuffleOrder) { - null -> contextPlay.takeIf { it in 0 until contextSize } - else -> order.getOrNull(contextPlay.toInt()) - } + fun flatContext(contextPlay: Long): Long? { + if (contextPlay !in 0 until contextSize) return null + return shuffleSeed?.let { Shuffle.toFlat(it, contextSize, contextPlay) } ?: contextPlay + } + + fun playContext(flat: Long): Long { + if (flat !in 0 until contextSize) return flat + return shuffleSeed?.let { Shuffle.toSequence(it, contextSize, flat) } ?: flat + } fun locate(position: Long): Pair? { if (isUpNext(position)) return locateIn(upNext, upNextIndex(position)) @@ -134,12 +138,6 @@ class QueueRepository( ) { private val dao get() = db.queueDao() - @Volatile - private var cachedShuffleSeed = 0L - - @Volatile - private var cachedShuffleOrder: LongArray? = null - private val queueVersion = AtomicLong() @Volatile @@ -204,13 +202,13 @@ class QueueRepository( suspend fun replace( entries: List, - shuffleOrder: LongArray? = null, + shuffleSeed: Long? = null, upNext: List = emptyList(), upNextAnchor: Long = 0, ) { write(entries) writeUpNext(upNext) - setShuffle(shuffleOrder != null, shuffleOrder) + setShuffle(shuffleSeed != null, shuffleSeed ?: 0L) cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) } @@ -228,11 +226,12 @@ class QueueRepository( ResolvedQueueEntry(entry, lengthOf(entry, version)) } val row = dao.cursor() - val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null - LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L) + val size = entries.sumOf { it.length } + val seed = row?.takeIf { it.shuffleEnabled && size > 0L && it.shuffleSize == size }?.shuffleSeed + LoadedSnapshot(entries, upNext, seed, row?.upNextAnchor ?: 0L) } } - return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version) + return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleSeed, loaded.anchor, version) } suspend fun modes(): QueueModes { @@ -242,24 +241,22 @@ class QueueRepository( suspend fun setShuffle( enabled: Boolean, - order: LongArray?, + seed: Long, ) { - val seed = if (enabled && order != null) System.nanoTime() else 0L + val size = if (enabled) contextSize() else 0L cursorMutex.withLock { - db.useWriterConnection { transactor -> - transactor.immediateTransaction { - dao.clearShuffleOrder() - if (enabled && order != null) { - dao.insertShuffleOrder(order.mapIndexed { index, flat -> ShuffleOrder(index.toLong(), flat) }) - } - dao.setCursor(cursorRow().copy(shuffleEnabled = enabled, shuffleSeed = seed)) - } - } + dao.setCursor( + cursorRow().copy( + shuffleEnabled = enabled, + shuffleSeed = if (enabled) seed else 0L, + shuffleSize = size, + ), + ) } - cachedShuffleSeed = 0 - cachedShuffleOrder = null } + private suspend fun setShuffleSize(size: Long) = cursorMutex.withLock { dao.setCursor(cursorRow().copy(shuffleSize = size)) } + fun invalidateLibraryCache() { cachedFlatIds = null lengthCache.clear() @@ -268,18 +265,6 @@ class QueueRepository( suspend fun setRepeat(mode: Int) = cursorMutex.withLock { 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, @@ -303,9 +288,7 @@ class QueueRepository( ): Long? { val flat = flatIds(snapshot).indexOf(songId) if (flat < 0) return null - val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong() - if (play < 0) return null - return snapshot.combined(play) + return snapshot.combined(snapshot.playContext(flat.toLong())) } suspend fun range( @@ -407,18 +390,18 @@ class QueueRepository( return snapshot() } val play = snapshot.contextPlay(position) - val order = snapshot.shuffleOrder - if (order == null) { + if (snapshot.shuffled) { + // The permutation is derived from (seed, size), so removing a track re-derives it at + // the smaller size; keep the same seed and store the new size so the queue stays + // shuffled. There is no stable "next" to preserve here. + val flat = snapshot.flatContext(play) ?: return snapshot + write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) + adjustAnchorOnRemove(flat) + setShuffleSize(contextSize()) + } else { val removed = compact(removeEntry(snapshot.entries, play)) write(removed.map { it.entry }) adjustAnchorOnRemove(play) - } else { - val flat = order.getOrNull(play.toInt()) ?: return snapshot - write(compact(removeEntry(snapshot.entries, flat)).map { it.entry }) - val reordered = order.toMutableList().apply { removeAt(play.toInt()) } - for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1 - setShuffle(true, reordered.toLongArray()) - adjustAnchorOnRemove(flat) } return snapshot() } @@ -466,16 +449,12 @@ class QueueRepository( return true } if (fromUpNext != toUpNext) return false + // Reordering the context while shuffled would have to make the derived order match the + // drop, which needs materialising the order again. The queue view disables context drags + // while shuffled; manual ordering goes through the up-next block instead. + if (snapshot.shuffled) return false val playFrom = snapshot.contextPlay(from) val playTo = snapshot.contextPlay(to) - val order = snapshot.shuffleOrder - if (order != null) { - val reordered = order.toMutableList() - val moved = reordered.removeAt(playFrom.toInt()) - reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved) - setShuffle(true, reordered.toLongArray()) - return true - } val located = snapshot.locateContext(playFrom) ?: return false val song = itemAt(snapshot, from) ?: return false val removed = removeEntry(snapshot.entries, playFrom) @@ -503,6 +482,8 @@ class QueueRepository( private suspend fun cursorRow(): PlaybackCursor = dao.cursor() ?: PlaybackCursor(queuePosition = 0) + private suspend fun contextSize(): Long = dao.entries().sumOf { it.resolvedLength() } + private suspend fun write(entries: List) { db.useWriterConnection { transactor -> transactor.immediateTransaction { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt b/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt new file mode 100644 index 00000000..bcde387e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/Shuffle.kt @@ -0,0 +1,97 @@ +package com.subtracks.data.repo + +/** + * A deterministic, seekable permutation of `[0, size)`: `toFlat(seed, size, s)` is the track played at + * shuffled position `s` and `toSequence` is its inverse. Nothing per track is stored; the order is derived + * from the seed and the queue length. + * + * The permutation is a balanced four-round Feistel network over the smallest power of two domain that + * covers `size`, with `[size, domain)` skipped by cycle walking. A Feistel round is always a bijection, + * so the only requirement is that the two halves have equal width; cycle walking keeps it a bijection on + * `[0, size)`. The round function is a SplitMix64 mix of the seed, the half value and the round index. + */ +object Shuffle { + private const val ROUNDS = 4 + private const val MAX_SIZE = 1L shl 40 + + private val GOLDEN = 0x9E3779B97F4A7C15uL.toLong() + private val MIX_A = 0xBF58476D1CE4E5B9uL.toLong() + private val MIX_B = 0x94D049BB133111EBuL.toLong() + + fun toFlat( + seed: Long, + size: Long, + sequence: Long, + ): Long { + require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" } + if (size <= 1L) return 0L + if (sequence !in 0 until size) return sequence + var value = encrypt(seed, sequence, halfBits(size)) + while (value >= size) value = encrypt(seed, value, halfBits(size)) + return value + } + + fun toSequence( + seed: Long, + size: Long, + flat: Long, + ): Long { + require(size <= MAX_SIZE) { "queue of $size tracks is too large to shuffle" } + if (size <= 1L) return 0L + if (flat !in 0 until size) return flat + var value = decrypt(seed, flat, halfBits(size)) + while (value >= size) value = decrypt(seed, value, halfBits(size)) + return value + } + + private fun halfBits(size: Long): Int { + if (size <= 1L) return 1 + val bits = 64 - java.lang.Long.numberOfLeadingZeros(size - 1) + return (bits + 1) / 2 + } + + private fun encrypt( + seed: Long, + value: Long, + halfBits: Int, + ): Long { + val mask = (1L shl halfBits) - 1 + var left = value ushr halfBits + var right = value and mask + for (round in 0 until ROUNDS) { + val nextLeft = right + val nextRight = left xor (mix(seed, right, round) and mask) + left = nextLeft + right = nextRight + } + return (left shl halfBits) or right + } + + private fun decrypt( + seed: Long, + value: Long, + halfBits: Int, + ): Long { + val mask = (1L shl halfBits) - 1 + var left = value ushr halfBits + var right = value and mask + for (round in ROUNDS - 1 downTo 0) { + val previousLeft = right xor (mix(seed, left, round) and mask) + val previousRight = left + left = previousLeft + right = previousRight + } + return (left shl halfBits) or right + } + + private fun mix( + seed: Long, + value: Long, + round: Int, + ): Long { + var z = seed + value * GOLDEN + round * MIX_A + z = (z xor (z ushr 30)) * MIX_A + z = (z xor (z ushr 27)) * MIX_B + return z xor (z ushr 31) + } +} diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 413d8d55..83e5276c 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.QueueKind import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueSnapshot import com.subtracks.data.repo.QueueWindowItem +import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -21,7 +22,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlin.random.Random data class QueueItem( @@ -59,7 +59,7 @@ private sealed interface QueueUndo { val entries: List val cursor: Long val current: Long? - val shuffleOrder: LongArray? + val shuffleSeed: Long? val upNext: List val upNextAnchor: Long } @@ -70,7 +70,7 @@ private data class RemovedUndo( override val current: Long, val position: Long, val item: QueueItem?, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo @@ -81,7 +81,7 @@ private data class MovedUndo( override val current: Long, val from: Long, val to: Long, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo @@ -89,7 +89,7 @@ private data class MovedUndo( private data class ReloadUndo( override val entries: List, override val cursor: Long, - override val shuffleOrder: LongArray?, + override val shuffleSeed: Long?, override val upNext: List, override val upNextAnchor: Long, ) : QueueUndo { @@ -253,14 +253,15 @@ class PlaybackController( return@withLock } val start = randomIndex(snap.contextSize, avoid) - val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) } - queueRepository.setShuffle(true, order) + val seed = Random.nextLong() + val position = Shuffle.toSequence(seed, snap.contextSize, start) + queueRepository.setShuffle(true, seed) shuffleEnabled = true queueSourceId = entry.sourceId - queueRepository.setCursor(0) + queueRepository.setCursor(position) snap = queueRepository.snapshot() this@PlaybackController.snapshot = snap - loadWindow(0, autoplay = true) + loadWindow(position, autoplay = true) } private fun randomIndex( @@ -314,7 +315,7 @@ class PlaybackController( if (snapshot.size == 1L) { queueRepository.removeAt(snapshot, position) stopLocked() - lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) return@withLock } val current = currentPosition() ?: return@withLock @@ -327,24 +328,40 @@ class PlaybackController( windowJob?.cancel() if (updated.size == 0L) { stopLocked() - lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) return@withLock } val currentId = player.currentItem?.id + val fallback = + if (snapshot.shuffled && !wasUpNext) { + // The permutation is re-derived at the smaller size, so there is no stable + // "next"; play whatever now fills the removed track's slot. + val seed = snapshot.shuffleSeed!! + val removedFlat = snapshot.flatContext(snapshot.contextPlay(position)) ?: 0L + val newContextSize = (snapshot.contextSize - 1).coerceAtLeast(0) + if (newContextSize == 0L) { + 0L + } else { + val slot = removedFlat.coerceIn(0, newContextSize - 1) + updated.combined(Shuffle.toSequence(seed, newContextSize, slot)) + } + } else { + (if (position < current) current - 1 else current) + } val target = ( currentId?.let { if (wasUpNext) queueRepository.combinedIndexOf(updated, it) else queueRepository.combinedContextIndexOf(updated, it) - } ?: (if (position < current) current - 1 else current) + } ?: fallback ).coerceIn(0, updated.size - 1) lastPosition = target lastEdit = if (removed == null) { - ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } else { - RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } - if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) { + if (snapshot.upNextSize > 0L || updated.upNextSize > 0L || snapshot.shuffled) { queueRepository.setCursor(target) if (position != current) { rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target) @@ -410,9 +427,9 @@ class PlaybackController( val toInWindow = to in windowStart..windowEnd lastEdit = if (fromInWindow == toInWindow) { - MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + MovedUndo(entries, cursor, target, from, to, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } else { - ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) + ReloadUndo(entries, cursor, snapshot.shuffleSeed, upNext, snapshot.upNextAnchor) } when { fromInWindow && toInWindow -> { @@ -452,8 +469,8 @@ class PlaybackController( val player = player ?: return@withLock val playing = player.playWhenReady val positionMs = player.currentPositionMs - queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor) - shuffleEnabled = undo.shuffleOrder != null + queueRepository.replace(undo.entries, undo.shuffleSeed, undo.upNext, undo.upNextAnchor) + shuffleEnabled = undo.shuffleSeed != null val restored = readSnapshot() this.snapshot = restored lastEdit = null @@ -702,11 +719,11 @@ class PlaybackController( val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock windowJob?.cancel() val enabling = !shuffleEnabled + val seed = if (enabling) Random.nextLong() else 0L if (enabling) { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) } - queueRepository.setShuffle(true, order) + queueRepository.setShuffle(true, seed) } else { - queueRepository.setShuffle(false, null) + queueRepository.setShuffle(false, 0L) } shuffleEnabled = enabling val reordered = readSnapshot() @@ -714,7 +731,7 @@ class PlaybackController( val position = when { wasUpNext -> reordered.anchorPlay + 1 + blockIndex - enabling -> reordered.combined(0) + enabling -> reordered.combined(Shuffle.toSequence(seed, reordered.contextSize, flat)) else -> reordered.combined(flat) } val anchor = queueRepository.reanchorFor(reordered, position) @@ -784,9 +801,9 @@ class PlaybackController( val start = startPosition.coerceIn(0, snapshot.contextSize - 1) val position = if (shuffleEnabled) { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) } - queueRepository.setShuffle(true, order) - 0L + val seed = Random.nextLong() + queueRepository.setShuffle(true, seed) + Shuffle.toSequence(seed, snapshot.contextSize, start) } else { start } @@ -808,9 +825,9 @@ class PlaybackController( var snapshot = queueRepository.snapshot() if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) { val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1) - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) } - queueRepository.setShuffle(true, order) - queueRepository.setCursor(0) + val seed = Random.nextLong() + queueRepository.setShuffle(true, seed) + queueRepository.setCursor(Shuffle.toSequence(seed, snapshot.contextSize, start)) snapshot = queueRepository.snapshot() } else if (!snapshot.shuffled) { shuffleEnabled = false @@ -1072,20 +1089,12 @@ class PlaybackController( 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 suspend fun readSnapshot(): QueueSnapshot { val snapshot = queueRepository.snapshot() if (!snapshot.shuffled) { - if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, null) + if (shuffleEnabled && snapshot.size > 0L) queueRepository.setShuffle(false, 0L) shuffleEnabled = false } return snapshot 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 49032676..a62b8dff 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -2,6 +2,7 @@ package com.subtracks.data.repo import android.content.Context import androidx.room3.Room +import androidx.room3.useReaderConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -440,15 +441,20 @@ class QueueRepositoryTest { runTest { seedLibrary() val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(true, longArrayOf(2, 0, 1)) + repository.setShuffle(true, 12345L) val shuffled = repository.snapshot() + val contextBefore = (0 until shuffled.contextSize).mapNotNull { repository.itemAt(shuffled, it)?.song?.id } repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false) val updated = repository.snapshot() assertEquals(4, updated.size) - assertEquals("s5", repository.itemAt(updated, 1)?.song?.id) - assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated)) + assertEquals("s5", repository.itemAt(updated, updated.anchorPlay + 1)?.song?.id) + val contextAfter = + (0 until updated.size) + .filterNot { updated.isUpNext(it) } + .mapNotNull { repository.itemAt(updated, it)?.song?.id } + assertEquals(contextBefore, contextAfter) assertTrue(updated.shuffled) } @@ -535,11 +541,11 @@ class QueueRepositoryTest { (0 until snapshot.size).mapNotNull { repository.itemAt(snapshot, it)?.song?.id } @Test - fun aStaleShuffleOrderIsIgnoredWhenTheQueueLengthChanges() = + fun aStaleShuffleSizeIsIgnoredWhenTheQueueLengthChanges() = runTest { seedLibrary() repository.replace(listOf(repository.albumEntry(1, "al1"))) - repository.setShuffle(true, longArrayOf(2, 0, 1)) + repository.setShuffle(true, 1234L) assertTrue(repository.snapshot().shuffled) db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album"))) @@ -548,6 +554,91 @@ class QueueRepositoryTest { assertFalse(repository.snapshot().shuffled) } + @Test + fun enablingShuffleStoresNoPerTrackRows() = + runTest { + seedLibrary() + repository.replace(listOf(repository.albumEntry(1, "al1"))) + val entriesBefore = db.queueDao().entries().size + + repository.setShuffle(true, 5678L) + val snapshot = repository.snapshot() + + assertTrue(snapshot.shuffled) + assertEquals(3L, snapshot.contextSize) + assertEquals(entriesBefore, db.queueDao().entries().size) + assertFalse(shuffleOrderTableExists()) + } + + private suspend fun shuffleOrderTableExists(): Boolean = + db.useReaderConnection { transactor -> + transactor.usePrepared( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'shuffle_order'", + ) { statement -> + if (statement.step()) statement.getLong(0) > 0 else false + } + } + + @Test + fun shuffledPositionsResolveToEveryTrackExactlyOnce() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.setShuffle(true, 42L) + val shuffled = repository.snapshot() + + val resolved = (0 until shuffled.size).mapNotNull { repository.itemAt(shuffled, it)?.song?.id } + + assertEquals(3, resolved.distinct().size) + assertEquals(listOf("s1", "s2", "s3"), resolved.sorted()) + val flatPositions = (0 until shuffled.size).map { shuffled.flatContext(it)!! } + assertEquals((0L until shuffled.size).toList(), flatPositions.sorted()) + } + + @Test + fun togglingShuffleOffRestoresTheOriginalOrder() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.setShuffle(true, 99L) + assertTrue(repository.snapshot().shuffled) + + repository.setShuffle(false, 0L) + + val restored = repository.snapshot() + assertFalse(restored.shuffled) + assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored)) + } + + @Test + fun shuffledRangeResolvesEntriesInPositionOrder() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))) + repository.setShuffle(true, 42L) + val snapshot = repository.snapshot() + + val range = repository.range(snapshot, 0, snapshot.size - 1) + + assertEquals((0L until snapshot.size).toList(), range.map { it.position }) + assertEquals(setOf("s1", "s2", "s3", "s4"), range.map { it.item.song.id }.toSet()) + } + + @Test + fun shuffledRangeResolvesASplitEntryWithoutRepeatingOrSkipping() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.removeAt(snapshot, 1) + repository.setShuffle(true, 7L) + val shuffled = repository.snapshot() + + val range = repository.range(shuffled, 0, shuffled.size - 1) + + assertEquals(listOf(0L, 1L), range.map { it.position }) + assertEquals(setOf("s1", "s3"), range.map { it.item.song.id }.toSet()) + } + private suspend fun seedLibrary() { db.sourcesDao().upsertSource( Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), diff --git a/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt new file mode 100644 index 00000000..c6ab9846 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/ShuffleTest.kt @@ -0,0 +1,113 @@ +package com.subtracks.data.repo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShuffleTest { + private val sizes = + listOf( + 1L, + 2L, + 3L, + 4L, + 5L, + 7L, + 8L, + 9L, + 15L, + 16L, + 17L, + 31L, + 32L, + 33L, + 63L, + 64L, + 65L, + 100L, + 127L, + 128L, + 129L, + 255L, + 256L, + 257L, + 999L, + 1000L, + 1023L, + 1024L, + 1025L, + 4096L, + 65535L, + 65536L, + 65537L, + 100_000L, + 999_983L, + ) + + private val seeds = listOf(0L, 1L, -1L, 42L, Long.MIN_VALUE, Long.MAX_VALUE, 0x5DEECE66DL) + + @Test + fun mapsEverySizeAsABijection() { + for (size in sizes) { + for (seed in seeds) { + val mapped = (0 until size).map { Shuffle.toFlat(seed, size, it) } + assertEquals("size=$size seed=$seed has duplicates", mapped.distinct().size, size.toInt()) + assertTrue("size=$size seed=$seed out of range", mapped.all { it in 0 until size }) + for (sequence in 0 until size) { + assertEquals( + "size=$size seed=$seed sequence=$sequence did not round trip", + sequence, + Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence)), + ) + } + } + } + } + + @Test + fun mapsLargeSizesAsABijectionOnSamples() { + for (size in listOf(1_000_003L, 16_777_216L, 16_777_217L, 1_000_000_000L)) { + for (seed in seeds) { + val samples = (0 until 5000).map { size / 5000 * it + it % 97 } + val mapped = samples.map { Shuffle.toFlat(seed, size, it) } + assertEquals(mapped.distinct().size, mapped.size) + assertTrue(mapped.all { it in 0 until size }) + samples.forEach { sequence -> + assertEquals(sequence, Shuffle.toSequence(seed, size, Shuffle.toFlat(seed, size, sequence))) + } + } + } + } + + @Test + fun spreadsTheFirstPositionAcrossSeeds() { + val size = 1000L + val buckets = LongArray(10) + val draws = 2000 + for (i in 0 until draws) { + val flat = Shuffle.toFlat(i.toLong() * 2654435761L + 17L, size, 0L) + buckets[(flat * buckets.size / size).toInt()]++ + } + val expected = draws / buckets.size + buckets.forEach { count -> + assertTrue("bucket $count is far from $expected", count in expected / 2..expected * 2) + } + } + + @Test + fun doesNotLeaveMostTracksInPlace() { + val size = 1000L + for (seed in seeds) { + val moved = (0 until size).count { Shuffle.toFlat(seed, size, it) != it } + assertTrue("seed=$seed left too many tracks in place", moved > size * 9 / 10) + } + } + + @Test + fun consecutivePositionsAreNotASingleStride() { + val size = 1000L + val order = (0 until size).map { Shuffle.toFlat(7L, size, it) } + val strides = order.zipWithNext { a, b -> ((b - a) % size + size) % size }.toSet() + assertTrue("the order looks like one fixed stride", strides.size > size / 2) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 5f2b52fb..d6ccf537 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -17,6 +17,7 @@ import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.Shuffle import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher @@ -1526,12 +1527,13 @@ class PlaybackControllerTest { controller.state.value.item ?.id, ) - assertEquals(0L, controller.state.value.position) - assertEquals("s3", handle.items.first().id) + val shuffled = runBlocking { queues.snapshot() } + assertEquals("s3", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id }) controller.toggleShuffle() await { !controller.state.value.shuffle } + assertEquals(2L, controller.state.value.position) assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id }) assertEquals( "s3", @@ -1540,6 +1542,109 @@ class PlaybackControllerTest { ) } + @Test + fun removingATrackWhileShuffledKeepsTheCurrentTrackPlaying() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val currentId = + controller.state.value.item!! + .id + val removeAt = (controller.state.value.position!! + 1) % 6 + + runBlocking { controller.removeAt(removeAt) } + await { + controller.state.value.item + ?.id == currentId && controller.state.value.shuffle + } + + assertTrue(handle.items.map { it.id }.contains(currentId)) + val remaining = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + assertEquals(5, remaining.size) + assertEquals(5, remaining.distinct().size) + } + + @Test + fun removingTheCurrentTrackWhileShuffledPlaysTheSongThatFillsItsSlot() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val current = controller.state.value.position!! + val before = runBlocking { queues.snapshot() } + val removedFlat = before.flatContext(current)!! + + runBlocking { controller.removeAt(current) } + + val after = runBlocking { queues.snapshot() } + val newContextSize = (before.contextSize - 1).coerceAtLeast(0) + val slot = removedFlat.coerceIn(0, (newContextSize - 1).coerceAtLeast(0)) + val expected = after.combined(Shuffle.toSequence(before.shuffleSeed!!, newContextSize, slot)) + assertEquals(5L, after.size) + assertEquals(expected, controller.state.value.position) + assertEquals( + runBlocking { queues.itemAt(after, expected)?.song?.id }, + controller.state.value.item + ?.id, + ) + } + + @Test + fun movingContextTracksWhileShuffledIsIgnored() { + seedAlbum(6, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + controller.toggleShuffle() + await { controller.state.value.shuffle } + val currentId = + controller.state.value.item!! + .id + val before = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + val position = controller.state.value.position!! + val from = (position + 1) % 6 + val to = (position + 3) % 6 + + runBlocking { controller.move(from, to) } + + val after = + runBlocking { + val snap = queues.snapshot() + (0 until snap.size).mapNotNull { queues.itemAt(snap, it)?.song?.id } + } + assertEquals(before, after) + assertEquals( + currentId, + controller.state.value.item + ?.id, + ) + } + @Test fun nextWrapsToTheStartWhenRepeating() { seedAlbum(3, sourceId = 1) @@ -1679,8 +1784,9 @@ class PlaybackControllerTest { ?.id == "s4" } - assertEquals(0L, controller.state.value.position) - assertEquals("s4", handle.items.first().id) + assertEquals("s4", handle.currentItem?.id) + val shuffled = runBlocking { queues.snapshot() } + assertEquals("s4", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id }) } @Test @@ -1761,8 +1867,15 @@ class PlaybackControllerTest { await { controller.state.value.item != null && controller.state.value.shuffle } assertTrue(controller.state.value.shuffle) - assertEquals(0L, controller.state.value.position) - assertTrue(handle.items.first().id in listOf("s1", "s2", "s3", "s4", "s5")) + assertTrue( + controller.state.value.item!! + .id in listOf("s1", "s2", "s3", "s4", "s5"), + ) + assertEquals( + controller.state.value.item!! + .id, + handle.currentItem?.id, + ) } @Test diff --git a/docs/architecture.md b/docs/architecture.md index c84e1b5d..1dd25d9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,7 +84,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer `QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server. -Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. +Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index cd192112..6f7aa5eb 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -57,7 +57,7 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo Follow-up work for very large libraries, playlists and long sessions, grouped so each block can be one PR. - **Library read path.** Index the sort keys used by album/artist/playlist browsing (a denormalized `albumArtist` on `albums` plus `NOCASE` indices) and replace `LIMIT 1 OFFSET` with keyset/cursor seeks in `QueueRepository`/`QueueDao`. Shared dependency for the read-side fixes; can split into "indices" then "keyset" if too large for one review. -- **Shuffle.** Drop `shuffle_order` and the retained whole-order/whole-id arrays; derive the permutation from `(seed, size)` and resolve positions through the same positional lookup. Recommended after the read-path PR, since it shares `QueueRepository`. +- **Shuffle.** Landed: the permutation is derived from `(seed, size)` (a seekable Feistel in `Shuffle.kt`) and positions resolve through the same positional lookup, so `shuffle_order` and the retained whole-order/whole-id arrays are gone. The context is read-only while shuffled; manual ordering goes through the up-next block. - **Sync pipeline.** Prune with a memory-bounded id set, remove `fetchRanks` and the write-only rank columns, fetch playlists/albums with bounded concurrency and skip unchanged ones, and raise/make configurable the page cap with a clear "library too large" failure. Independent of the read path. - **Queue view and edits.** Bound the queue view's loaded rows (or page over the queue ordinal) and scope its ViewModel to the overlay; compact adjacent same-ref ranges after an edit and cache resolved entry lengths. The view half is independent; the edit half shares `QueueRepository` with the read-path PR. - **Artwork.** Give `artwork_seeds` a `sourceId` with `ON DELETE CASCADE` and an LRU/TTL (or drop the table and rely on Coil's disk cache); prefetch the thumbnail, not the original, on now-playing transitions. Independent.