diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/16.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/16.json new file mode 100644 index 00000000..cec8b22f --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/16.json @@ -0,0 +1,1129 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "9e720b89a451c4b6aee8c97fbdfbab5b", + "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, `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": "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": "shuffle_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flatPosition", + "columnName": "flatPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sequence" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `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, '9e720b89a451c4b6aee8c97fbdfbab5b')" + ] + } +} \ 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 eb1667c0..fdab336e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -144,6 +144,25 @@ val MIGRATION_14_15 = } } +val MIGRATION_15_16 = + object : Migration(15, 16) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `up_next_entries` (`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 )", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `up_next_entries` (`sourceId`)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `up_next_entries` (`position`)", + ) + connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `upNextAnchor` INTEGER NOT NULL DEFAULT 0") + } + } + private suspend fun rebuildTable( connection: SQLiteConnection, table: String, @@ -232,4 +251,5 @@ val MIGRATIONS: Array = MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) 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 7ddfa76c..8a9f0518 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -8,6 +8,7 @@ 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 private const val ALBUM_SONGS_SQL = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + @@ -31,6 +32,15 @@ interface QueueDao { @Query("SELECT * FROM queue_entries ORDER BY position, id") suspend fun entries(): List + @Query("DELETE FROM up_next_entries") + suspend fun clearUpNext() + + @Insert + suspend fun insertUpNext(entries: List) + + @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 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 37e8d8f7..24f71f52 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -16,6 +16,7 @@ import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.Song import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource +import com.subtracks.data.model.UpNextEntry @Database( entities = [ @@ -28,11 +29,12 @@ import com.subtracks.data.model.SubsonicSource PlaylistSong::class, Song::class, QueueEntry::class, + UpNextEntry::class, PlaybackCursor::class, ShuffleOrder::class, ArtworkSeed::class, ], - version = 15, + version = 16, 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 adcd6cce..7d9f8035 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -265,6 +265,39 @@ data class QueueEntry( val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } } +@Entity( + tableName = "up_next_entries", + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId"), Index("position")], +) +data class UpNextEntry( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val position: Long, + val sourceId: Long, + val kind: QueueKind, + val refId: String, + val rangeStart: Long? = null, + val rangeEnd: Long? = null, +) { + val offset: Long get() = rangeStart ?: 0 + + val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) } + + fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd) + + companion object { + fun from(entry: QueueEntry) = + UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd) + } +} + @Entity(tableName = "playback_cursor") data class PlaybackCursor( @PrimaryKey val id: Long = 1, @@ -273,6 +306,7 @@ data class PlaybackCursor( @ColumnInfo(defaultValue = "0") val repeatMode: Int = 0, @ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0, @ColumnInfo(defaultValue = "0") val positionMs: Long = 0, + @ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0, ) @Entity(tableName = "shuffle_order") 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 720c9273..d76f4938 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -10,6 +10,7 @@ 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 import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -29,6 +30,13 @@ private data class EntryLengthKey( val count: Long?, ) +private data class LoadedSnapshot( + val entries: List, + val upNext: List, + val shuffleOrder: LongArray?, + val anchor: Long, +) + data class ResolvedQueueEntry( val entry: QueueEntry, val length: Long, @@ -36,25 +44,59 @@ data class ResolvedQueueEntry( data class QueueSnapshot( val entries: List, + val upNext: List = emptyList(), val shuffleOrder: LongArray? = null, + val upNextAnchor: Long = 0, val version: Long = 0, ) { - val size: Long = entries.sumOf { it.length } + val contextSize: Long = entries.sumOf { it.length } + + val upNextSize: Long = upNext.sumOf { it.length } + + val size: Long = contextSize + upNextSize 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()) - - private fun locateFlat(position: Long): Pair? { - var remaining = position - for (resolved in entries) { - if (remaining < resolved.length) return resolved.entry to remaining - remaining -= resolved.length + val anchorPlay: Long = + when (val order = shuffleOrder) { + null -> upNextAnchor + else -> order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L } - return null + + fun isUpNext(position: Long): Boolean = upNextSize > 0L && position > anchorPlay && position <= anchorPlay + upNextSize + + fun upNextIndex(position: Long): Long = position - anchorPlay - 1L + + fun contextPlay(position: Long): Long = if (position <= anchorPlay) position else position - upNextSize + + fun anchorContextPlay(position: Long): Long = if (isUpNext(position)) anchorPlay else contextPlay(position) + + 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 locate(position: Long): Pair? { + if (isUpNext(position)) return locateIn(upNext, upNextIndex(position)) + return locateContext(contextPlay(position)) } + + fun locateContext(contextPlay: Long): Pair? = locateIn(entries, flatContext(contextPlay) ?: return null) +} + +private fun locateIn( + entries: List, + index: Long, +): Pair? { + var remaining = index + for (resolved in entries) { + if (remaining < resolved.length) return resolved.entry to remaining + remaining -= resolved.length + } + return null } data class QueueModes( @@ -65,6 +107,7 @@ data class QueueModes( data class QueueWindowItem( val position: Long, val item: SongListItem, + val upNext: Boolean = false, ) class QueueRepository( @@ -137,10 +180,15 @@ class QueueRepository( suspend fun replace( entries: List, shuffleOrder: LongArray? = null, + upNext: List = emptyList(), + upNextAnchor: Long = 0, ) { write(entries) + writeUpNext(upNext) setShuffle(shuffleOrder != null, shuffleOrder) - cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0)) } + cursorMutex.withLock { + dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor)) + } } suspend fun snapshot(): QueueSnapshot { @@ -149,15 +197,17 @@ class QueueRepository( db.useReaderConnection { transactor -> transactor.deferredTransaction { val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) } + val upNext = + dao.upNextEntries().map { + val entry = it.toQueueEntry() + ResolvedQueueEntry(entry, lengthOf(entry, version)) + } val row = dao.cursor() - val shuffled = row?.shuffleEnabled == true - val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null - Triple(entries, order, shuffled) + val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null + LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L) } } - val (entries, order, shuffled) = loaded - val valid = if (shuffled && order == null) null else order - return QueueSnapshot(entries, valid, version) + return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version) } suspend fun modes(): QueueModes { @@ -209,29 +259,21 @@ class QueueRepository( snapshot: QueueSnapshot, position: Long, ): SongListItem? { - if (snapshot.shuffled) { - val sourceId = - snapshot.entries - .firstOrNull() - ?.entry - ?.sourceId ?: return null - val flat = snapshot.flatPosition(position) ?: return null - val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null - return dao.songsByIds(sourceId, listOf(id)).firstOrNull() - } val (entry, offset) = snapshot.locate(position) ?: return null return rows(entry, entry.offset + offset, 1).firstOrNull() } - suspend fun flatIndexOf( + suspend fun combinedIndexOf( snapshot: QueueSnapshot, songId: String, ): Long? { - val order = snapshot.shuffleOrder ?: return null + val upIndex = idsFor(snapshot.upNext).indexOf(songId) + if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex val flat = flatIds(snapshot).indexOf(songId) if (flat < 0) return null - val position = order.indexOf(flat.toLong()) - return if (position >= 0) position.toLong() else null + val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong() + if (play < 0) return null + return snapshot.combined(play) } suspend fun range( @@ -239,36 +281,20 @@ class QueueRepository( first: Long, last: Long, ): List { - if (snapshot.shuffled) { - val sourceId = - snapshot.entries - .firstOrNull() - ?.entry - ?.sourceId ?: return emptyList() - val ids = flatIds(snapshot) - val requested = mutableListOf>() - for (position in first..last) { - val flat = snapshot.flatPosition(position) ?: continue - val id = ids.getOrNull(flat.toInt()) ?: continue - requested += position to id - } - if (requested.isEmpty()) return emptyList() - val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id } - return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } } - } + val from = first.coerceAtLeast(0) + val to = last.coerceAtMost(snapshot.size - 1) + if (from > to) return emptyList() + val anchor = snapshot.anchorPlay + val block = snapshot.upNextSize val items = mutableListOf() - var start = 0L - for (resolved in snapshot.entries) { - val end = start + resolved.length - 1 - val entryStart = start - start += resolved.length - if (resolved.length == 0L || end < first || entryStart > last) continue - val from = maxOf(first, entryStart) - val to = minOf(last, end) - val offset = resolved.entry.offset + (from - entryStart) - rows(resolved.entry, offset, (to - from + 1).toInt()) - .forEachIndexed { index, item -> items += QueueWindowItem(from + index, item) } - } + val beforeEnd = minOf(to, anchor) + if (from <= beforeEnd) items += contextPlayRange(snapshot, from, beforeEnd) + val blockFirst = maxOf(from, anchor + 1) + val blockLast = minOf(to, anchor + block) + if (blockFirst <= blockLast) items += blockPlayRange(snapshot, blockFirst - anchor - 1, blockLast - anchor - 1) + val afterFirst = maxOf(from, anchor + block + 1) + if (afterFirst <= to) items += contextPlayRange(snapshot, afterFirst - block, to - block) + items.sortBy { it.position } return items } @@ -281,41 +307,133 @@ class QueueRepository( return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1)) } + suspend fun addUpNext( + snapshot: QueueSnapshot, + currentPosition: Long, + entry: QueueEntry, + playNext: Boolean, + ): Long { + val list = snapshot.upNext.map { it.entry }.toMutableList() + val active = snapshot.isUpNext(currentPosition) + val insertIndex = + when { + playNext && active -> 1 + playNext -> 0 + else -> list.size + }.coerceIn(0, list.size) + list.add(insertIndex, entry.copy(id = 0, position = 0)) + val anchor = + if (snapshot.upNextSize == 0L) { + val play = snapshot.anchorContextPlay(currentPosition) + snapshot.flatContext(play) ?: 0L + } else { + snapshot.upNextAnchor + } + writeUpNext(list) + cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) } + if (snapshot.upNextSize == 0L) return currentPosition + val before = snapshot.upNext.take(insertIndex).sumOf { it.length } + return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition + } + + suspend fun removeUpNextAt( + snapshot: QueueSnapshot, + index: Long, + ) { + if (index !in 0 until snapshot.upNextSize) return + writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry }) + } + suspend fun removeAt( snapshot: QueueSnapshot, position: Long, ): QueueSnapshot { - val order = snapshot.shuffleOrder - if (order == null) { - write(compact(removeEntry(snapshot.entries, position)).map { it.entry }) + if (snapshot.isUpNext(position)) { + removeUpNextAt(snapshot, snapshot.upNextIndex(position)) return snapshot() } - val flat = order.getOrNull(position.toInt()) ?: return snapshot - write(compact(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()) + val play = snapshot.contextPlay(position) + val order = snapshot.shuffleOrder + if (order == null) { + val removed = compact(removeEntry(snapshot.entries, play)) + write(removed.map { it.entry }) + adjustAnchorOnRemove(play, removed.sumOf { it.length }) + } else { + val flat = order.getOrNull(play.toInt()) ?: return snapshot + val removed = compact(removeEntry(snapshot.entries, flat)) + write(removed.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, removed.sumOf { it.length }) + } return snapshot() } + private suspend fun adjustAnchorOnRemove( + removedFlat: Long, + newSize: Long, + ) = cursorMutex.withLock { + val row = cursorRow() + val anchor = row.upNextAnchor + val adjusted = + when { + anchor > removedFlat -> anchor - 1 + anchor == removedFlat -> removedFlat.coerceIn(0, (newSize - 1).coerceAtLeast(0)) + else -> anchor + } + if (adjusted != anchor) dao.setCursor(row.copy(upNextAnchor = adjusted)) + } + + private suspend fun adjustAnchorOnMove( + flatFrom: Long, + flatTo: Long, + ) = cursorMutex.withLock { + val row = cursorRow() + val anchor = row.upNextAnchor + val moved = + when { + flatFrom == anchor -> flatTo + flatFrom < anchor && flatTo >= anchor -> anchor - 1 + flatFrom > anchor && flatTo <= anchor -> anchor + 1 + else -> anchor + } + if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved)) + } + suspend fun move( snapshot: QueueSnapshot, from: Long, to: Long, ): Boolean { + val fromUpNext = snapshot.isUpNext(from) + val toUpNext = snapshot.isUpNext(to) + if (fromUpNext && toUpNext) { + val song = itemAt(snapshot, from) ?: return false + val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false + val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from)) + val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L) + writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry }) + return true + } + if (fromUpNext != toUpNext) 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(from.toInt()) - reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved) + 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.locate(from) ?: return false + val located = snapshot.locateContext(playFrom) ?: return false val song = itemAt(snapshot, from) ?: return false - val removed = removeEntry(snapshot.entries, from) + val removed = removeEntry(snapshot.entries, playFrom) val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L) - write(compact(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved)).map { it.entry }) + val insertAt = playTo.coerceIn(0, removed.sumOf { it.length }) + write(compact(insertEntry(removed, insertAt, moved)).map { it.entry }) + adjustAnchorOnMove(playFrom, insertAt) return true } @@ -347,27 +465,109 @@ class QueueRepository( cachedFlatIds = null } + private suspend fun writeUpNext(entries: List) { + db.useWriterConnection { transactor -> + transactor.immediateTransaction { + dao.clearUpNext() + dao.insertUpNext( + entries.mapIndexed { index, entry -> + UpNextEntry.from(entry).copy(id = 0, position = index.toLong()) + }, + ) + } + } + queueVersion.incrementAndGet() + } + private suspend fun flatIds(snapshot: QueueSnapshot): List { val cached = cachedFlatIds - if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.size) return cached - val ids = ArrayList(snapshot.size.toInt()) - for (resolved in snapshot.entries) { - val entry = resolved.entry - val entryIds = - when (entry.kind) { - QueueKind.Song -> listOf(entry.refId) - QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId) - QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId) - } - val from = entry.offset.toInt().coerceIn(0, entryIds.size) - val to = (entry.offset + resolved.length).toInt().coerceIn(from, entryIds.size) - ids.addAll(entryIds.subList(from, to)) - } + if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached + val ids = idsFor(snapshot.entries) cachedFlatIds = ids cachedFlatVersion = snapshot.version return ids } + private suspend fun idsFor(resolved: List): List { + val ids = ArrayList() + for (entry in resolved) { + val ref = entry.entry + val entryIds = + when (ref.kind) { + QueueKind.Song -> listOf(ref.refId) + QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId) + QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId) + } + val from = ref.offset.toInt().coerceIn(0, entryIds.size) + val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size) + ids.addAll(entryIds.subList(from, to)) + } + return ids + } + + private suspend fun contextPlayRange( + snapshot: QueueSnapshot, + first: Long, + last: Long, + ): List { + if (first > last) return emptyList() + val items = mutableListOf() + if (snapshot.shuffled) { + val sourceId = + snapshot.entries + .firstOrNull() + ?.entry + ?.sourceId ?: return emptyList() + val ids = flatIds(snapshot) + val requested = mutableListOf>() + for (play in first..last) { + val flat = snapshot.flatContext(play) ?: continue + val id = ids.getOrNull(flat.toInt()) ?: continue + requested += play to id + } + if (requested.isEmpty()) return emptyList() + val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id } + requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } } + return items + } + var start = 0L + for (resolved in snapshot.entries) { + val end = start + resolved.length - 1 + val entryStart = start + start += resolved.length + if (resolved.length == 0L || end < first || entryStart > last) continue + val from = maxOf(first, entryStart) + val to = minOf(last, end) + val offset = resolved.entry.offset + (from - entryStart) + rows(resolved.entry, offset, (to - from + 1).toInt()) + .forEachIndexed { index, item -> items += QueueWindowItem(snapshot.combined(from + index), item) } + } + return items + } + + private suspend fun blockPlayRange( + snapshot: QueueSnapshot, + first: Long, + last: Long, + ): List { + if (first > last) return emptyList() + val items = mutableListOf() + var start = 0L + for (resolved in snapshot.upNext) { + val end = start + resolved.length - 1 + val entryStart = start + start += resolved.length + if (resolved.length == 0L || end < first || entryStart > last) continue + val from = maxOf(first, entryStart) + val to = minOf(last, end) + val offset = resolved.entry.offset + (from - entryStart) + rows(resolved.entry, offset, (to - from + 1).toInt()).forEachIndexed { index, item -> + items += QueueWindowItem(snapshot.anchorPlay + 1 + from + index, item, upNext = true) + } + } + return items + } + private suspend fun rows( entry: QueueEntry, offset: Long, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 4b74bf3a..f77ba681 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -59,6 +59,8 @@ private sealed interface QueueUndo { val cursor: Long val current: Long? val shuffleOrder: LongArray? + val upNext: List + val upNextAnchor: Long } private data class RemovedUndo( @@ -68,6 +70,8 @@ private data class RemovedUndo( val position: Long, val item: QueueItem?, override val shuffleOrder: LongArray?, + override val upNext: List, + override val upNextAnchor: Long, ) : QueueUndo private data class MovedUndo( @@ -77,12 +81,16 @@ private data class MovedUndo( val from: Long, val to: Long, override val shuffleOrder: LongArray?, + override val upNext: List, + override val upNextAnchor: Long, ) : QueueUndo private data class ReloadUndo( override val entries: List, override val cursor: Long, override val shuffleOrder: LongArray?, + override val upNext: List, + override val upNextAnchor: Long, ) : QueueUndo { override val current: Long? = null } @@ -121,6 +129,7 @@ class PlaybackController( private var queueSourceId: Long? = null private var windowStart = 0L private var windowEnd = -1L + private var lastPosition: Long? = null private var updating = false private var lastEdit: QueueUndo? = null private var shuffleEnabled = false @@ -234,15 +243,15 @@ class PlaybackController( val previous = snapshot val previousEntry = previous?.entries?.firstOrNull()?.entry val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId - val avoid = if (same) currentPosition()?.let { previous.flatPosition(it) } else null + val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null queueRepository.replace(listOf(entry)) var snap = queueRepository.snapshot() - if (snap.size == 0L) { + if (snap.contextSize == 0L) { stopLocked() return@withLock } - val start = randomIndex(snap.size, avoid) - val order = withContext(Dispatchers.Default) { shuffledOrder(snap.size, start) } + val start = randomIndex(snap.contextSize, avoid) + val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) } queueRepository.setShuffle(true, order) shuffleEnabled = true queueSourceId = entry.sourceId @@ -272,6 +281,7 @@ class PlaybackController( queueRepository.setCursor(target) if (target in windowStart..windowEnd) { player.ensurePrepared() + lastPosition = target player.seekToIndex((target - windowStart).toInt()) player.play() shiftWindowLocked(target) @@ -290,10 +300,11 @@ class PlaybackController( val player = player ?: return@withLock val cursor = queueRepository.cursor() val entries = snapshot.entries.map { it.entry } + val upNext = snapshot.upNext.map { it.entry } if (snapshot.size == 1L) { queueRepository.removeAt(snapshot, position) stopLocked() - lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder) + lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) return@withLock } val current = currentPosition() ?: return@withLock @@ -305,9 +316,9 @@ class PlaybackController( windowJob?.cancel() lastEdit = if (removed == null) { - ReloadUndo(entries, cursor, snapshot.shuffleOrder) + ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) } else { - RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder) + RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) } when { position == current -> { @@ -347,6 +358,7 @@ class PlaybackController( val current = currentPosition() ?: return@withLock val cursor = queueRepository.cursor() val entries = snapshot.entries.map { it.entry } + val upNext = snapshot.upNext.map { it.entry } if (!queueRepository.move(snapshot, from, to)) return@withLock this.snapshot = readSnapshot() val target = movedCursor(current, from, to) @@ -355,9 +367,9 @@ class PlaybackController( val toInWindow = to in windowStart..windowEnd lastEdit = if (fromInWindow == toInWindow) { - MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder) + MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) } else { - ReloadUndo(entries, cursor, snapshot.shuffleOrder) + ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor) } when { fromInWindow && toInWindow -> { @@ -397,7 +409,7 @@ class PlaybackController( val player = player ?: return@withLock val playing = player.playWhenReady val positionMs = player.currentPositionMs - queueRepository.replace(undo.entries, undo.shuffleOrder) + queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor) shuffleEnabled = undo.shuffleOrder != null val restored = readSnapshot() this.snapshot = restored @@ -488,12 +500,33 @@ class PlaybackController( val snapshot = snapshot ?: return if (snapshot.size == 0L) return val current = currentPosition() ?: return + if (snapshot.isUpNext(current)) { + scope.launch { consumeUpNext(current) } + return + } when { current < snapshot.size - 1 -> jumpTo(current + 1) repeatMode != RepeatMode.Off -> jumpTo(0) } } + private suspend fun consumeUpNext(position: Long) = + startLock.withLock { + val snapshot = snapshot ?: return@withLock + if (!snapshot.isUpNext(position)) return@withLock + queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position)) + val updated = readSnapshot() + this.snapshot = updated + windowJob?.cancel() + endedHandled = false + queueRepository.setCursor(position) + if (position < updated.size) { + loadWindow(position, autoplay = true) + } else { + refresh(position) + } + } + fun previous() { val player = player ?: return if (player.currentPositionMs > RESTART_THRESHOLD_MS) { @@ -522,6 +555,45 @@ class PlaybackController( } } + fun addToQueue( + sourceId: Long, + kind: QueueKind, + refId: String, + ) = enqueue(sourceId, kind, refId, playNext = false) + + fun playNext( + sourceId: Long, + kind: QueueKind, + refId: String, + ) = enqueue(sourceId, kind, refId, playNext = true) + + private fun enqueue( + sourceId: Long, + kind: QueueKind, + refId: String, + playNext: Boolean, + ) { + val entry = QueueEntry(position = 0, sourceId = sourceId, kind = kind, refId = refId) + scope.launch { enqueue(entry, playNext) } + } + + private suspend fun enqueue( + entry: QueueEntry, + playNext: Boolean, + ) = startLock.withLock { + val snapshot = snapshot ?: return@withLock + val player = player ?: return@withLock + if (snapshot.size == 0L) return@withLock + val current = currentPosition() ?: return@withLock + val position = queueRepository.addUpNext(snapshot, current, entry, playNext) + val updated = readSnapshot() + this.snapshot = updated + queueRepository.setCursor(position) + windowJob?.cancel() + rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position) + refresh(position) + } + fun cycleRepeat() { val next = when (repeatMode) { @@ -543,22 +615,27 @@ class PlaybackController( if (snapshot.size == 0L) return@withLock player ?: return@withLock val current = currentPosition() ?: return@withLock - val flat = snapshot.flatPosition(current) ?: return@withLock + val wasUpNext = snapshot.isUpNext(current) + val blockIndex = snapshot.upNextIndex(current) + val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock windowJob?.cancel() - val position = - if (shuffleEnabled) { - queueRepository.setShuffle(false, null) - shuffleEnabled = false - flat - } else { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, flat) } - queueRepository.setShuffle(true, order) - shuffleEnabled = true - 0L - } - queueRepository.setCursor(position) + val enabling = !shuffleEnabled + if (enabling) { + val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) } + queueRepository.setShuffle(true, order) + } else { + queueRepository.setShuffle(false, null) + } + shuffleEnabled = enabling val reordered = readSnapshot() this@PlaybackController.snapshot = reordered + val position = + when { + wasUpNext -> reordered.anchorPlay + 1 + blockIndex + enabling -> reordered.combined(0) + else -> reordered.combined(flat) + } + queueRepository.setCursor(position) rebuildWindow(queueRepository.window(reordered, position, QUEUE_WINDOW_RADIUS), position) endedHandled = false refresh(position) @@ -618,10 +695,10 @@ class PlaybackController( stopLocked() return@withLock } - val start = startPosition.coerceIn(0, snapshot.size - 1) + val start = startPosition.coerceIn(0, snapshot.contextSize - 1) val position = if (shuffleEnabled) { - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) } + val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) } queueRepository.setShuffle(true, order) 0L } else { @@ -643,9 +720,9 @@ class PlaybackController( repeatMode = modes.repeat.toRepeatMode() player?.setRepeatOne(repeatMode == RepeatMode.One) var snapshot = queueRepository.snapshot() - if (shuffleEnabled && !snapshot.shuffled && snapshot.size > 0L) { - val start = queueRepository.cursor().coerceIn(0, snapshot.size - 1) - val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) } + 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) snapshot = queueRepository.snapshot() @@ -680,6 +757,7 @@ class PlaybackController( queueSourceId = null windowStart = 0 windowEnd = -1 + lastPosition = null lastEdit = null player?.run { stop() @@ -702,6 +780,7 @@ class PlaybackController( if (startIndex < 0) return windowStart = window.first().position windowEnd = window.last().position + lastPosition = position updating = true player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) updating = false @@ -745,6 +824,7 @@ class PlaybackController( player.seekToIndex((dest - windowStart).toInt()) if (wasEnded) player.play() endedHandled = false + lastPosition = dest refresh(dest) } } @@ -755,6 +835,12 @@ class PlaybackController( val snapshot = snapshot ?: return if (snapshot.size == 0L) return val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1) + val previous = lastPosition + lastPosition = position + if (previous != null && previous != position && snapshot.isUpNext(previous)) { + consumeUpNext(previous) + return + } lastSavedPositionMs = 0 queueRepository.setCursor(position) refresh(position) @@ -782,7 +868,7 @@ class PlaybackController( if (previous != null && previous.shuffled != snapshot.shuffled) { val currentId = player.currentItem?.id val position = - (currentId?.let { queueRepository.flatIndexOf(snapshot, it) } ?: center) + (currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center) .coerceIn(0, snapshot.size - 1) queueRepository.setCursor(position) rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position) @@ -835,6 +921,7 @@ class PlaybackController( } windowStart = window.first().position windowEnd = window.last().position + lastPosition = position } private fun currentPosition(): Long? { @@ -900,7 +987,9 @@ class PlaybackController( } private fun contextAt(position: Long?): QueueContext? { - val entry = position?.let { snapshot?.locate(it)?.first } ?: return null + val snapshot = snapshot ?: return null + val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null + val entry = snapshot.locateContext(play)?.first ?: return null return QueueContext(entry.kind, entry.sourceId, entry.refId) } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 3bb34644..df9fcc4e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material3.ExperimentalMaterial3Api @@ -41,6 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.QueueKind import com.subtracks.data.source.StarType import kotlinx.coroutines.launch import com.subtracks.data.model.Album as AlbumModel @@ -90,12 +93,28 @@ sealed interface MenuTarget { } } +data class QueueRef( + val sourceId: Long, + val kind: QueueKind, + val refId: String, +) + +fun MenuTarget.queueRef(): QueueRef? = + when (this) { + is MenuTarget.Song -> QueueRef(song.sourceId, QueueKind.Song, song.id) + is MenuTarget.Album -> QueueRef(album.sourceId, QueueKind.Album, album.id) + is MenuTarget.Playlist -> QueueRef(playlist.sourceId, QueueKind.Playlist, playlist.id) + is MenuTarget.Artist -> null + } + class ItemActions( val playSong: ((SongModel) -> Unit)? = null, val playAlbum: (AlbumModel) -> Unit = {}, val shuffleAlbum: (AlbumModel) -> Unit = {}, val playPlaylist: (PlaylistModel) -> Unit = {}, val shufflePlaylist: (PlaylistModel) -> Unit = {}, + val playNext: ((QueueRef) -> Unit)? = null, + val addToQueue: ((QueueRef) -> Unit)? = null, val setStar: suspend (StarType, String, Boolean) -> Result = { _, _, _ -> Result.success(Unit) }, val viewAlbum: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null, @@ -139,6 +158,19 @@ fun ItemContextMenu( } } + val queueRef = target.queueRef() + + @Composable + fun queueItems() { + if (queueRef == null) return + actions.playNext?.let { next -> + MenuItem(Icons.Rounded.SkipNext, "Play next") { dismiss { next(queueRef) } } + } + actions.addToQueue?.let { add -> + MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, "Add to queue") { dismiss { add(queueRef) } } + } + } + ModalBottomSheet( onDismissRequest = { dismiss() }, sheetState = sheetState, @@ -151,6 +183,7 @@ fun ItemContextMenu( actions.playSong?.let { play -> MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } } } + queueItems() StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) } target.song.albumId?.let { albumId -> actions.viewAlbum?.let { view -> @@ -171,6 +204,7 @@ fun ItemContextMenu( is MenuTarget.Album -> { MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } + queueItems() StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) } target.album.artistId?.let { artistId -> actions.viewArtist?.let { view -> @@ -188,6 +222,7 @@ fun ItemContextMenu( is MenuTarget.Playlist -> { MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } } + queueItems() } } Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index e0ae16f9..cfcbc5be 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -63,6 +63,8 @@ fun AlbumDetailRoute( playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) }, playAlbum = { viewModel.playAll() }, shuffleAlbum = { viewModel.shuffle() }, + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, setStar = setStar, viewArtist = onViewArtist, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 9c4934ed..6c043f21 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -214,6 +214,8 @@ fun LibraryRoute( shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, playPlaylist = { viewModel.playPlaylist(it.id) }, shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index b182f0fc..5d9ae8da 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -49,6 +49,8 @@ fun PlaylistDetailRoute( ItemActions( playPlaylist = { viewModel.playAll() }, shufflePlaylist = { viewModel.shuffle() }, + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, 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 ff37c271..564ebb39 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -105,6 +105,8 @@ fun NowPlayingRoute( val context = LocalPlatformContext.current val actions = ItemActions( + playNext = { controller.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { controller.addToQueue(it.sourceId, it.kind, it.refId) }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index 929d501b..2cee2b23 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -75,6 +76,7 @@ data class QueueRow( val id: Long, val position: Long, val song: SongListItem, + val upNext: Boolean = false, ) class QueueViewModel( @@ -95,7 +97,7 @@ class QueueViewModel( private var nextId = 0L private val mutex = Mutex() - private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item) + private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext) private fun reusedIds(): Map> = rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() } @@ -106,7 +108,7 @@ class QueueViewModel( ): QueueRow { val ids = reused[item.item.song.id] val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++ - return QueueRow(id, item.position, item.item) + return QueueRow(id, item.position, item.item, item.upNext) } fun open() { @@ -169,6 +171,7 @@ class QueueViewModel( ) { if (fromIndex == toIndex) return if (fromIndex !in rows.indices || toIndex !in rows.indices) return + if (rows[fromIndex].upNext != rows[toIndex].upNext) return rows.add(toIndex, rows.removeAt(fromIndex)) } @@ -256,6 +259,12 @@ fun QueueRoute( sourceRepository: SourceRepository = koinInject(), ) { val playback by controller.state.collectAsStateWithLifecycle() + val queueContext = playback.context + val fallbackTitle = "Next from here" + val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) } + LaunchedEffect(queueContext) { + sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle + } LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() } QueueScreen( rows = viewModel.rows, @@ -263,6 +272,8 @@ fun QueueRoute( initialIndex = viewModel.initialIndex, generation = viewModel.generation, currentSongId = playback.item?.id, + shuffle = playback.shuffle, + contextTitle = sourceTitle.value, coverArt = sourceRepository::coverArt, onBack = onBack, onPlay = viewModel::play, @@ -294,6 +305,8 @@ fun QueueScreen( modifier: Modifier = Modifier, initialIndex: Int = 0, generation: Int = 0, + shuffle: Boolean = false, + contextTitle: String? = null, ) { val listState = rememberLazyListState() val fill = rememberViewportFill(listState) @@ -360,6 +373,10 @@ fun QueueScreen( ) { items(count = rows.size, key = { rows[it].id }) { index -> val row = rows[index] + if (index == 0 || rows[index - 1].upNext != row.upNext) { + QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up") + } + val draggable = row.upNext || !shuffle ReorderableItem( state = reorderState, key = row.id, @@ -376,26 +393,35 @@ fun QueueScreen( floating = isDragging, coverArt = coverArt, dragHandle = - Modifier.draggableHandle( - onDragStarted = { - dragId = row.id - dragFrom = row.position - }, - onDragStopped = { - val id = dragId - val from = dragFrom - if (id != null && from != null) { - val index = rows.indexOfFirst { it.id == id } - val to = if (index < 0) from else dropTarget(rows, index, from) - if (to != from) { - onMove(from, to) - showUndo("Queue reordered") + if (!draggable) { + null + } else { + Modifier.draggableHandle( + onDragStarted = { + dragId = row.id + dragFrom = row.position + }, + onDragStopped = { + val id = dragId + val from = dragFrom + if (id != null && from != null) { + val index = rows.indexOfFirst { it.id == id } + val to = if (index < 0) from else dropTarget(rows, index, from) + val left = rows.getOrNull(index - 1)?.upNext + val right = rows.getOrNull(index + 1)?.upNext + val sameSection = + (left == null || left == row.upNext) && + (right == null || right == row.upNext) + if (to != from && sameSection) { + onMove(from, to) + showUndo("Queue reordered") + } } - } - dragId = null - dragFrom = null - }, - ), + dragId = null + dragFrom = null + }, + ) + }, onClick = { onPlay(row.position) }, onRemove = { onRemove(row.position) @@ -411,6 +437,16 @@ fun QueueScreen( } } +@Composable +private fun QueueSectionHeader(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} + internal fun dropTarget( rows: List, index: Int, @@ -428,7 +464,7 @@ private fun QueueRowItem( isPlaying: Boolean, floating: Boolean, coverArt: (String?, Boolean) -> CoverArtRef?, - dragHandle: Modifier, + dragHandle: Modifier?, onClick: () -> Unit, onRemove: () -> Unit, ) { @@ -446,8 +482,10 @@ private fun QueueRowItem( modifier = Modifier.size(20.dp), ) } - Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder") + if (dragHandle != null) { + Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder") + } } } }, 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 d32c17be..feddbc26 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -372,6 +372,142 @@ class QueueRepositoryTest { assertEquals(4, repository.snapshot().size) } + @Test + fun upNextResolvesAfterTheCurrentTrack() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + val updated = repository.snapshot() + assertEquals(4, updated.size) + assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated)) + } + + @Test + fun playNextGoesToTheFrontOfTheUpNextQueue() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + repository.addUpNext( + repository.snapshot(), + currentPosition = 0, + entry = repository.songEntry(1, "s5"), + playNext = true, + ) + + assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun removingAQueuedTrackDropsItFromTheCombinedQueue() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + repository.removeAt(repository.snapshot(), 1) + + val updated = repository.snapshot() + assertEquals(3, updated.size) + assertEquals(listOf("s1", "s2", "s3"), resolveAll(updated)) + } + + @Test + fun movingAQueuedTrackReordersTheUpNextQueue() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + repository.addUpNext( + repository.snapshot(), + currentPosition = 0, + entry = repository.songEntry(1, "s5"), + playNext = false, + ) + assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot())) + + repository.move(repository.snapshot(), from = 2, to = 1) + + assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun queuedTracksNeverJoinAShuffledContext() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.setShuffle(true, longArrayOf(2, 0, 1)) + val shuffled = repository.snapshot() + + 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)) + assertTrue(updated.shuffled) + } + + @Test + fun consumingASliceOfAQueuedAlbumRemovesTheRightTrack() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false) + assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot())) + + repository.removeAt(repository.snapshot(), 2) + + assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun removingTheLastAnchorTrackKeepsTheQueueResolvable() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false) + assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot())) + + repository.removeAt(repository.snapshot(), 2) + + val updated = repository.snapshot() + assertEquals(3, updated.size) + assertEquals(listOf("s1", "s2", "s4"), resolveAll(updated)) + } + + @Test + fun removingAContextTrackBeforeTheAnchorKeepsTheQueueAttached() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false) + assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot())) + + repository.removeAt(repository.snapshot(), 0) + + assertEquals(listOf("s2", "s4", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun replacingTheContextClearsTheUpNextQueue() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false) + + repository.replace(listOf(repository.songEntry(1, "s5"))) + + val updated = repository.snapshot() + assertTrue(updated.upNext.isEmpty()) + assertEquals(1, updated.size) + assertEquals(listOf("s5"), resolveAll(updated)) + } + private fun albumRange( start: Long, end: Long, diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index acb07b78..fcb279e2 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -183,8 +183,10 @@ class FakePlayerHandle : PlayerHandle { override fun seekToIndex(index: Int) { operations += "seekToIndex($index)" + val changed = index != this.index this.index = index ended = false + if (changed) listeners.forEach { it.onTransition() } notifyEvents() } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index c2edddba..110a10ce 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -192,6 +192,167 @@ class PlaybackControllerTest { assertTrue(controller.state.value.hasPrevious) } + @Test + fun aQueuedTrackPlaysNextAndIsConsumed() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + await { runBlocking { controller.upcomingItem()?.id } == "x1" } + + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + + controller.next() + await { + controller.state.value.item + ?.id == "s2" + } + assertEquals("s3", runBlocking { controller.upcomingItem()?.id }) + } + + @Test + fun addingToTheQueueFromAMiddleTrackPlaysTheQueuedTrackNext() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 1) + await { + controller.state.value.item + ?.id == "s2" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + await { runBlocking { controller.upcomingItem()?.id } == "x1" } + + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + + controller.next() + await { + controller.state.value.item + ?.id == "s3" + } + } + + @Test + fun playNextGoesInFrontOfTheQueuedTracks() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + seedSong("x2", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + controller.playNext(1, QueueKind.Song, "x2") + await { runBlocking { controller.upcomingItem()?.id } == "x2" } + + controller.next() + await { + controller.state.value.item + ?.id == "x2" + } + + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + } + + @Test + fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + + controller.playAt(3) + await { + controller.state.value.item + ?.id == "s3" + } + + assertFalse(runBlocking { queues.snapshot().upNext }.isEmpty()) + } + + @Test + fun previousFromAQueuedTrackReturnsToTheContextWithoutConsuming() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + controller.next() + await { + controller.state.value.item + ?.id == "x1" + } + + controller.previous() + await { + controller.state.value.item + ?.id == "s1" + } + + assertEquals("x1", runBlocking { controller.upcomingItem()?.id }) + } + + @Test + fun startingANewContextClearsTheQueue() { + seedAlbum(3, sourceId = 1) + seedSong("x1", "al2") + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.addToQueue(1, QueueKind.Song, "x1") + await { runBlocking { controller.upcomingItem()?.id } == "x1" } + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty()) + } + @Test fun theDurationUsesTheNextTracksMetadataWhileItLoads() { seedAlbum(3, sourceId = 1) @@ -1152,6 +1313,32 @@ class PlaybackControllerTest { } } + private fun seedSong( + id: String, + albumId: String, + ) { + runBlocking { + db.libraryDao().upsertSongs( + listOf( + Song( + sourceId = 1, + id = id, + albumId = albumId, + artistId = "ar1", + title = "Song $id", + album = "Other Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ), + ), + ) + } + } + private fun await(predicate: () -> Boolean) { val deadline = System.nanoTime() + 5_000_000_000L while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) diff --git a/docs/architecture.md b/docs/architecture.md index 039eb3d9..0c903f27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,11 +84,13 @@ 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` (a flat index into the context) pins the block between a context track and its successor, and `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. + 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. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. -The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet. +The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics/gapless format preferences, scrobbling and downloads are not wired yet. ## Data model diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d8236c30..cd192112 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -35,7 +35,8 @@ Not done yet in this slice (deliberately): cover-art tonal colour extraction, lo - (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player. - (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured). - (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo. -- Adding to the queue from the library (play next / add to queue), and gapless format preferences. +- (done) Adding to the queue from the library (play next / add to queue), backed by a separate, editable up-next list that plays between the current track and the rest of the context. +- Gapless format preferences. - Scrobbling and "now playing" back to the server. - Optional download/caching.