From c56a6d4f837afb4f6f38b41a0819c0d951694042 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:28:32 +0000 Subject: [PATCH] add library info dialogs and sort name preference Album and artist context menus gain an Info dialog covering the plural album artists, sort names and the play and download details the screens do not show. Multiple artist links collapse into one ellipsised entry that opens the artist chooser instead of listing each artist. Sort lists can now prefer regular names over sort names, or only use sort names for names without Latin letters. The new plain-name indexes keep those orders out of a temp B-tree. Also carry plural artists through the Media3 queue so Now Playing and the mini player keep them, drop the album artist tap ripple, and show the aggregate download progress in the tab bar where offline mode used to sit. --- .../37.json | 1997 +++++++++++++++++ .../com/subtracks/data/db/LibraryDao.kt | 53 +- .../com/subtracks/data/db/LibraryPaging.kt | 78 +- .../com/subtracks/data/db/Migrations.kt | 17 + .../subtracks/data/db/SubtracksDatabase.kt | 2 +- .../com/subtracks/data/model/Download.kt | 16 +- .../kotlin/com/subtracks/data/model/Models.kt | 3 + .../subtracks/data/prefs/UserPreferences.kt | 13 + .../subtracks/data/repo/LibraryRepository.kt | 192 +- .../main/kotlin/com/subtracks/di/AppModule.kt | 2 +- .../subtracks/playback/Media3PlayerHandle.kt | 11 + .../subtracks/playback/PlaybackController.kt | 2 + .../com/subtracks/playback/QueueItem.kt | 2 + .../main/kotlin/com/subtracks/ui/AppRoot.kt | 75 +- .../ui/components/HeroDetailScaffold.kt | 6 +- .../ui/components/ItemContextMenu.kt | 100 +- .../ui/components/LibraryInfoDialog.kt | 129 ++ .../com/subtracks/ui/components/SongArtist.kt | 8 +- .../subtracks/ui/components/SongInfoDialog.kt | 42 +- .../com/subtracks/ui/library/LibraryScreen.kt | 49 +- .../subtracks/ui/library/LibraryViewModel.kt | 25 + .../com/subtracks/ui/playback/MiniPlayer.kt | 3 +- .../subtracks/ui/playback/NowPlayingScreen.kt | 6 +- .../subtracks/ui/settings/SettingsScreen.kt | 67 + .../ui/settings/SettingsViewModel.kt | 10 + app/src/main/res/values/strings.xml | 19 + .../com/subtracks/data/db/LibraryDaoTest.kt | 89 +- .../data/prefs/UserPreferencesTest.kt | 16 + .../data/repo/LibraryRepositoryTest.kt | 1 + .../com/subtracks/playback/MediaItemTest.kt | 16 + .../com/subtracks/playback/QueueItemTest.kt | 24 + .../ui/components/DownloadIndicatorTest.kt | 13 + .../components/ItemContextMenuArtistTest.kt | 71 +- .../ui/components/ItemContextMenuInfoTest.kt | 86 + .../ui/components/LibraryInfoDialogTest.kt | 81 + .../ui/downloads/DownloadsViewModelTest.kt | 8 +- .../ui/library/LibraryViewModelTest.kt | 39 + .../ui/playback/NowPlayingRouteTest.kt | 2 +- .../ui/search/SearchViewModelTest.kt | 8 +- .../ui/settings/SettingsScreenTest.kt | 30 +- 40 files changed, 3191 insertions(+), 220 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/LibraryInfoDialog.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuInfoTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/LibraryInfoDialogTest.kt diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json new file mode 100644 index 00000000..09d97ac1 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/37.json @@ -0,0 +1,1997 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "89b0e7748931a3fa68a545f4e3a53d2b", + "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, `creditsStale` INTEGER NOT NULL DEFAULT 1)", + "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 + }, + { + "fieldPath": "creditsStale", + "columnName": "creditsStale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "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, `artistImageUrl` TEXT, `coverArts` TEXT NOT NULL DEFAULT '', `downloadedCoverArts` TEXT NOT NULL DEFAULT '', `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `songCount` INTEGER NOT NULL DEFAULT 0, `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `credited` INTEGER NOT NULL DEFAULT 1, 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" + }, + { + "fieldPath": "artistImageUrl", + "columnName": "artistImageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArts", + "columnName": "coverArts", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "downloadedCoverArts", + "columnName": "downloadedCoverArts", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "credited", + "columnName": "credited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "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", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_artists_name_plain", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_bios", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, PRIMARY KEY(`sourceId`, `artistId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "biography", + "columnName": "biography", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "artistId" + ] + }, + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `sortName`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "artists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE BEFORE UPDATE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_DELETE BEFORE DELETE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE AFTER UPDATE ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_INSERT AFTER INSERT ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END" + ] + }, + { + "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, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `albumArtistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "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", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `sortName`, `id`)" + }, + { + "name": "index_albums_name_plain", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtistSort", + "year", + "sortName", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)" + }, + { + "name": "index_albums_artist_plain", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `albumArtist`, `sortName`, `albumArtistSort`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtistSort", + "columnName": "albumArtistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "albums", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_UPDATE BEFORE UPDATE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_DELETE BEFORE DELETE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_UPDATE AFTER UPDATE ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_INSERT AFTER INSERT ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`, `sortName`, `albumArtistSort`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`, NEW.`sortName`, NEW.`albumArtistSort`); END" + ] + }, + { + "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": "album_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `position`), 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": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "position" + ] + }, + "indices": [ + { + "name": "index_album_artists_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "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_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`playlists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "playlists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_UPDATE BEFORE UPDATE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_DELETE BEFORE DELETE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_UPDATE AFTER UPDATE ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_INSERT AFTER INSERT ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "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, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, `trackGain` REAL, `albumGain` REAL, `trackPeak` REAL, `albumPeak` REAL, `artists` TEXT NOT NULL DEFAULT '', `sortName` TEXT NOT NULL COLLATE NOCASE DEFAULT '', `artistSort` TEXT NOT NULL COLLATE NOCASE DEFAULT '', 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": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + }, + { + "fieldPath": "trackGain", + "columnName": "trackGain", + "affinity": "REAL" + }, + { + "fieldPath": "albumGain", + "columnName": "albumGain", + "affinity": "REAL" + }, + { + "fieldPath": "trackPeak", + "columnName": "trackPeak", + "affinity": "REAL" + }, + { + "fieldPath": "albumPeak", + "columnName": "albumPeak", + "affinity": "REAL" + }, + { + "fieldPath": "artists", + "columnName": "artists", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "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`)" + }, + { + "name": "index_songs_sourceId_albumId_order", + "unique": false, + "columnNames": [ + "sourceId", + "albumId", + "disc", + "track", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `${TABLE_NAME}` (`sourceId`, `albumId`, `disc`, `track`, `id`)" + }, + { + "name": "index_songs_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "sortName", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_genres", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `genre` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId", + "position" + ] + }, + "indices": [ + { + "name": "index_song_genres_sourceId_genre", + "unique": false, + "columnNames": [ + "sourceId", + "genre" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_genres_sourceId_genre` ON `${TABLE_NAME}` (`sourceId`, `genre`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "song_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `songId`, `position`), FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId", + "position" + ] + }, + "indices": [ + { + "name": "index_song_artists_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "song_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`title`, `artist`, `sortName`, `artistSort`, tokenize=`trigram`, content=`songs`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "sortName", + "columnName": "sortName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSort", + "columnName": "artistSort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "songs", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_UPDATE BEFORE UPDATE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_BEFORE_DELETE BEFORE DELETE ON `songs` BEGIN DELETE FROM `song_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_UPDATE AFTER UPDATE ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_song_search_AFTER_INSERT AFTER INSERT ON `songs` BEGIN INSERT INTO `song_search`(`rowid`, `title`, `artist`, `sortName`, `artistSort`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`, NEW.`sortName`, NEW.`artistSort`); END" + ] + }, + { + "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" + ] + } + ] + }, + { + "tableName": "song_downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engineId", + "columnName": "engineId", + "affinity": "INTEGER" + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId" + ] + }, + "indices": [ + { + "name": "index_song_downloads_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_song_downloads_engineId", + "unique": false, + "columnNames": [ + "engineId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "pending_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `targetId` TEXT NOT NULL, `starType` TEXT, `time` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetId", + "columnName": "targetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starType", + "columnName": "starType", + "affinity": "TEXT" + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_actions_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_actions_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, '89b0e7748931a3fa68a545f4e3a53d2b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index f1436cc1..f818f7ff 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -50,11 +50,6 @@ internal const val ALBUMS_FILTER = "OR instr(lower(sortName), lower(:search)) > 0 " + "OR instr(lower(albumArtistSort), lower(:search)) > 0))) " -internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" -internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id" -internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = - "albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC" internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id" @@ -70,6 +65,16 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" +private const val ALBUM_NAME_SORT_KEY_MODE = + "(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE" +private const val ALBUM_ARTIST_SORT_KEY_MODE = + "(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE" +internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id" +internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC" +internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id" +internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED = + "$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC" + internal const val ARTIST_CREDITED = "(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)" @@ -285,8 +290,6 @@ internal const val ARTISTS_SELECT = "artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " + "artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited " -internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id" -internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id" @@ -296,6 +299,15 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC" +private const val ARTIST_NAME_SORT_KEY_MODE = + "(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE" +internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id" +internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC" + +private const val SONG_NAME_SORT_KEY_MODE = + "(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE" +internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id" + internal const val PLAYLISTS_FILTER = "FROM playlists WHERE sourceId = :sourceId " + "AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " + @@ -566,22 +578,24 @@ interface LibraryDao { @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " + - "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit", + "ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit", ) fun searchAlbums( sourceId: Long, search: String, limit: Int, + sortMode: String = "", ): Flow> @Query( "SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " + - "ORDER BY sortName COLLATE NOCASE, id LIMIT :limit", + "ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit", ) fun searchArtists( sourceId: Long, search: String, limit: Int, + sortMode: String = "", ): Flow> @Query( @@ -598,12 +612,13 @@ interface LibraryDao { "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " + - "ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit", + "ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit", ) fun searchSongs( sourceId: Long, search: String, limit: Int, + sortMode: String = "", ): Flow> @Query( @@ -745,36 +760,40 @@ interface LibraryDao { @RawQuery(observedEntities = [Playlist::class]) fun playlistsPaging(query: RoomRawQuery): PagingSource - @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME") + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE") fun albumsByName( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource - @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED") + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED") fun albumsByNameReversed( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource - @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST") + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE") fun albumsByArtist( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource - @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED") + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED") fun albumsByArtistReversed( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR") @@ -857,20 +876,22 @@ interface LibraryDao { downloadedFilter: Int = 0, ): PagingSource - @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME") + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE") fun artistsByName( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource - @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED") + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED") fun artistsByNameReversed( sourceId: Long, starredFilter: Int, search: String, downloadedFilter: Int = 0, + sortMode: String = "", ): PagingSource @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT") diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt index 6e1c23ec..984b710c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryPaging.kt @@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.PlaylistSort +import com.subtracks.data.prefs.SortNameMode // These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so // the resulting PagingSource is not invalidated by download writes (including progress updates). @@ -42,8 +43,9 @@ internal fun albumsDefaultQuery( search: String, sort: AlbumSort, descending: Boolean, + sortNameMode: SortNameMode = SortNameMode.SortName, ): RoomRawQuery { - val order = albumOrder(sort, descending) + val order = albumOrder(sort, descending, sortNameMode) return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt -> stmt.bindAlbumFilters(sourceId, starredFilter, search) } @@ -88,8 +90,9 @@ internal fun artistsDefaultQuery( search: String, sort: ArtistSort, descending: Boolean, + sortNameMode: SortNameMode = SortNameMode.SortName, ): RoomRawQuery { - val order = artistOrder(sort, descending) + val order = artistOrder(sort, descending, sortNameMode) return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt -> stmt.bindLong(1, sourceId) stmt.bindInt(2, starredFilter) @@ -121,27 +124,82 @@ internal fun playlistsDefaultQuery( } } +internal fun albumNameSortKey(mode: SortNameMode): String = + when (mode) { + SortNameMode.SortName -> "sortName COLLATE NOCASE" + SortNameMode.Name -> "name COLLATE NOCASE" + SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE" + } + +internal fun albumArtistSortKey(mode: SortNameMode): String = + when (mode) { + SortNameMode.SortName -> { + "albumArtistSort COLLATE NOCASE" + } + + SortNameMode.Name -> { + "albumArtist COLLATE NOCASE" + } + + SortNameMode.NonLatin -> { + "(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE" + } + } + +internal fun artistNameSortKey(mode: SortNameMode): String = + when (mode) { + SortNameMode.SortName -> "sortName COLLATE NOCASE" + SortNameMode.Name -> "name COLLATE NOCASE" + SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE" + } + private fun albumOrder( sort: AlbumSort, descending: Boolean, + mode: SortNameMode, ): String = when (sort) { - AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME - AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST - AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR - AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED - AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED - AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT - AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT + AlbumSort.Name -> { + val name = albumNameSortKey(mode) + if (descending) "$name DESC, id DESC" else "$name, id" + } + + AlbumSort.Artist -> { + val artist = albumArtistSortKey(mode) + val name = albumNameSortKey(mode) + if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id" + } + + AlbumSort.Year -> { + if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR + } + + AlbumSort.Added -> { + if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED + } + + AlbumSort.Starred -> { + if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED + } + + AlbumSort.Frequent -> { + if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT + } + + AlbumSort.Recent -> { + if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT + } } private fun artistOrder( sort: ArtistSort, descending: Boolean, + mode: SortNameMode, ): String = when (sort) { ArtistSort.Name -> { - if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME + val name = artistNameSortKey(mode) + if (descending) "$name DESC, id DESC" else "$name, id" } ArtistSort.AlbumCount -> { 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 6c3fe518..a12d1a12 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -706,6 +706,22 @@ val MIGRATION_35_36 = } } +val MIGRATION_36_37 = + object : Migration(36, 37) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " + + "ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)", + ) + } + } + internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean { val triggers = mutableListOf() connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement -> @@ -782,4 +798,5 @@ val MIGRATIONS: Array = MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, + MIGRATION_36_37, ) 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 908974c6..a663d027 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry SongDownload::class, PendingAction::class, ], - version = 36, + version = 37, exportSchema = true, ) @ColumnTypeConverters( diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index c01cebe1..0c71a512 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -58,9 +58,14 @@ data class SongDownload( @ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0, ) { val progress: Float? - get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } + get() = fractionOf(bytes, total) } +internal fun fractionOf( + bytes: Long, + total: Long, +): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } + enum class DownloadList { Album, Playlist, Artist } enum class BulkDownloadAction { Download, Cancel, Delete } @@ -73,6 +78,13 @@ data class ListDownloadStatus( val complete: Boolean get() = total > 0 && downloaded == total } +data class DownloadProgress( + val bytes: Long, + val total: Long, +) { + val fraction: Float? get() = fractionOf(bytes, total) +} + data class EntityDownloadStatus( val id: String, val total: Long = 0, @@ -99,7 +111,7 @@ data class DownloadedSong( val songSort: String = title, ) { val progress: Float? - get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } + get() = fractionOf(bytes, total) } data class DownloadArtwork( 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 40f940db..7aed1124 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -59,6 +59,7 @@ data class ArtistRef( indices = [ Index("sourceId"), Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]), + Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]), Index( name = "index_artists_albumCount", value = ["sourceId", "albumCount", "sortName", "id"], @@ -131,7 +132,9 @@ data class ArtistBio( Index("sourceId"), Index("sourceId", "artistId"), Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]), + Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]), Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]), + Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]), Index( name = "index_albums_year", value = ["sourceId", "year", "sortName", "id"], diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 5846925c..948af078 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +enum class SortNameMode { SortName, Name, NonLatin } + enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent } enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent } @@ -182,6 +184,17 @@ class UserPreferences( private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on") + fun sortNameMode(): Flow = + store.data.map { prefs -> + prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.SortName + } + + suspend fun setSortNameMode(mode: SortNameMode) { + store.edit { prefs -> prefs[sortNameModeKey] = mode.name } + } + + private val sortNameModeKey = stringPreferencesKey("sort_name_mode") + fun offlineMode(): Flow = store.data.map { prefs -> prefs[offlineModeKey] ?: false } suspend fun setOfflineMode(enabled: Boolean) { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 6ba3d6b1..b8a8f866 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.StarredFilter +import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.StarType import com.subtracks.log.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +@OptIn(ExperimentalCoroutinesApi::class) class LibraryRepository( private val db: SubtracksDatabase, private val sourceRepository: SourceRepository, private val serverActions: ServerActionSink, + private val userPreferences: UserPreferences, private val showMessage: (UiMessage) -> Unit = {}, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), ) { @@ -111,12 +116,18 @@ class LibraryRepository( fun searchAlbums( sourceId: Long, query: String, - ): Flow> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT) + ): Flow> = + userPreferences.sortNameMode().flatMapLatest { mode -> + db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name) + } fun searchArtists( sourceId: Long, query: String, - ): Flow> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT) + ): Flow> = + userPreferences.sortNameMode().flatMapLatest { mode -> + db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name) + } fun searchPlaylists( sourceId: Long, @@ -126,7 +137,10 @@ class LibraryRepository( fun searchSongs( sourceId: Long, query: String, - ): Flow> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT) + ): Flow> = + userPreferences.sortNameMode().flatMapLatest { mode -> + db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name) + } fun downloadedAlbumsPage(sourceId: Long): Flow> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) } @@ -178,66 +192,68 @@ class LibraryRepository( search: String, downloaded: Boolean, ): Flow> = - pager(40, enablePlaceholders = true) { - val dao = db.libraryDao() - if (!downloaded) { - dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) - } else { - val downloadedFilter = 1 - when (sort) { - AlbumSort.Name -> { - if (descending) { - dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter) + userPreferences.sortNameMode().flatMapLatest { mode -> + pager(40, enablePlaceholders = true) { + val dao = db.libraryDao() + if (!downloaded) { + dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode)) + } else { + val downloadedFilter = 1 + when (sort) { + AlbumSort.Name -> { + if (descending) { + dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } else { + dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } } - } - AlbumSort.Artist -> { - if (descending) { - dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Artist -> { + if (descending) { + dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } else { + dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } } - } - AlbumSort.Year -> { - if (descending) { - dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Year -> { + if (descending) { + dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Added -> { - if (descending) { - dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Added -> { + if (descending) { + dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Starred -> { - if (descending) { - dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Starred -> { + if (descending) { + dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Frequent -> { - if (descending) { - dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Frequent -> { + if (descending) { + dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - AlbumSort.Recent -> { - if (descending) { - dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + AlbumSort.Recent -> { + if (descending) { + dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } } } } @@ -252,50 +268,52 @@ class LibraryRepository( search: String, downloaded: Boolean, ): Flow> = - pager(60) { - val dao = db.libraryDao() - if (!downloaded) { - dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) - } else { - val downloadedFilter = 1 - when (sort) { - ArtistSort.Name -> { - if (descending) { - dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter) + userPreferences.sortNameMode().flatMapLatest { mode -> + pager(60) { + val dao = db.libraryDao() + if (!downloaded) { + dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode)) + } else { + val downloadedFilter = 1 + when (sort) { + ArtistSort.Name -> { + if (descending) { + dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } else { + dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name) + } } - } - ArtistSort.AlbumCount -> { - if (descending) { - dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.AlbumCount -> { + if (descending) { + dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Starred -> { - if (descending) { - dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Starred -> { + if (descending) { + dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Frequent -> { - if (descending) { - dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Frequent -> { + if (descending) { + dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } } - } - ArtistSort.Recent -> { - if (descending) { - dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) - } else { - dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + ArtistSort.Recent -> { + if (descending) { + dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index ce15c517..fb1225d2 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -96,7 +96,7 @@ fun appModule( scope = get(), ) } - single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) } + single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) } single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) } single { diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index ce33c723..07b11443 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import com.subtracks.R import com.subtracks.UiMessage +import com.subtracks.data.model.ArtistRef +import com.subtracks.data.model.ArtistRefsCodec import com.subtracks.data.model.AudioEncoding import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository @@ -229,6 +231,7 @@ class Media3PlayerHandle( .orEmpty(), artist = mediaItem.mediaMetadata.artist?.toString(), album = mediaItem.mediaMetadata.albumTitle?.toString(), + artists = mediaItem.artistRefs(), coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID), durationMs = mediaItem.mediaMetadata.extras @@ -238,7 +241,14 @@ class Media3PlayerHandle( ) } +internal fun MediaItem.artistRefs(): List = + mediaMetadata.extras + ?.getString(EXTRA_ARTISTS) + ?.let(ArtistRefsCodec::decode) + .orEmpty() + private const val EXTRA_COVER_ART_ID = "coverArtId" +private const val EXTRA_ARTISTS = "artists" private const val EXTRA_DURATION_MS = "durationMs" private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain" private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain" @@ -286,6 +296,7 @@ internal fun queueMediaItem( .setExtras( Bundle().apply { putString(EXTRA_COVER_ART_ID, item.coverArtId) + putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists)) item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain) putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 1e6a0603..c11673f1 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -1,6 +1,7 @@ package com.subtracks.playback import com.subtracks.UiMessage +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.QueueEntry @@ -36,6 +37,7 @@ data class QueueItem( val durationMs: Long? = null, val replayGain: ReplayGain = ReplayGain(), val albumContext: Boolean = false, + val artists: List = emptyList(), ) data class QueueContext( diff --git a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt index ceff896d..3b615bfe 100644 --- a/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt +++ b/app/src/main/kotlin/com/subtracks/playback/QueueItem.kt @@ -1,5 +1,6 @@ package com.subtracks.playback +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.SongItem fun SongItem.toQueueItem(albumContext: Boolean = false) = @@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) = id = song.id, title = song.title, artist = song.artist, + artists = song.artistRefs, album = song.album, coverArtId = coverArt, durationMs = song.duration?.let { it * 1000 }, diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 38c4dc9c..f83de668 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -61,14 +61,19 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.subtracks.R +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.Song import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.AlbumInfoDialog +import com.subtracks.ui.components.ArtistChooserSheet +import com.subtracks.ui.components.ArtistInfoDialog import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ItemContextMenu import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.SongInfoDialog import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.downloads.DownloadsRoute @@ -230,7 +235,8 @@ private fun MainNavigation() { var settleJob by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val contextMenuHost = remember { ContextMenuHost() } - var infoSong by remember { mutableStateOf(null) } + var infoTarget by remember { mutableStateOf(null) } + var artistChooser by remember { mutableStateOf?>(null) } val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null) var previousSourceId by rememberSaveable { mutableStateOf(null) } @@ -312,7 +318,8 @@ private fun MainNavigation() { nowPlayingLauncher.openNowPlaying.collect { focusManager.clearFocus() contextMenuHost.dismissTransients() - infoSong = null + infoTarget = null + artistChooser = null showingQueue = false if (navController.currentDestination?.route in DETAIL_ROUTES) { navController.popBackStack(Routes.LIBRARY, inclusive = false) @@ -389,6 +396,7 @@ private fun MainNavigation() { navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) }, onOpenSearch = { navController.navigate(Routes.SEARCH) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, onViewArtist = { artistId -> @@ -773,23 +781,62 @@ private fun MainNavigation() { target = target, actions = contextMenuHost.actions, onDismiss = { contextMenuHost.dismiss() }, - onInfo = { infoSong = it }, + onInfo = { infoTarget = it }, + onChooseArtists = { artistChooser = it }, ) } - infoSong?.let { song -> - val genres by - remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) } - .collectAsStateWithLifecycle(initialValue = emptyList()) - SongInfoDialog( - song = song, - download = downloads[song.id], - localFile = downloadRepository.localFile(song.id), - streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id }, - onDismiss = { infoSong = null }, - genres = genres, + artistChooser?.let { artists -> + ArtistChooserSheet( + artists = artists, + onArtistClick = { artist -> + artistChooser = null + navigateDetail( + Routes.ARTIST_DETAIL, + "artistId", + artist.id, + Routes.artist(artist.id), + ) + }, + onDismiss = { artistChooser = null }, ) } + + when (val target = infoTarget) { + is MenuTarget.Song -> { + val song = target.song + val genres by + remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) } + .collectAsStateWithLifecycle(initialValue = emptyList()) + SongInfoDialog( + song = song, + download = downloads[song.id], + localFile = downloadRepository.localFile(song.id), + streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id }, + onDismiss = { infoTarget = null }, + genres = genres, + ) + } + + is MenuTarget.Album -> { + AlbumInfoDialog( + album = target.album, + download = target.download, + onDismiss = { infoTarget = null }, + ) + } + + is MenuTarget.Artist -> { + ArtistInfoDialog( + artist = target.artist, + onDismiss = { infoTarget = null }, + ) + } + + is MenuTarget.Playlist, null -> { + Unit + } + } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 99b7d798..e3722663 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -291,7 +291,11 @@ fun HeroHeader( modifier = Modifier .heightIn(min = 32.dp) - .clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() }, + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClickLabel = stringResource(R.string.open_artist), + ) { onSubtitleClick() }, contentAlignment = Alignment.TopCenter, ) { HeroSubtitle(subtitle, Modifier.padding(top = 4.dp)) 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 5ad0a80c..925227ff 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Cancel @@ -50,6 +51,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.subtracks.R +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.DownloadList @@ -197,7 +199,8 @@ fun ItemContextMenu( target: MenuTarget, actions: ItemActions, onDismiss: () -> Unit, - onInfo: ((SongModel) -> Unit)? = null, + onInfo: ((MenuTarget) -> Unit)? = null, + onChooseArtists: ((List) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -210,6 +213,14 @@ fun ItemContextMenu( } } + fun dismissThen(after: () -> Unit) { + scope.launch { + sheetState.hide() + onDismiss() + after() + } + } + val queueRef = target.queueRef() @Composable @@ -256,18 +267,9 @@ fun ItemContextMenu( } } } - target.song.artistRefs.forEach { artist -> - actions.viewArtist?.let { view -> - MenuItem( - Icons.Rounded.Person, - artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), - ) { - dismiss { view(artist.id) } - } - } - } + ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::dismissThen) onInfo?.let { info -> - MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } } + MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } } } } @@ -279,15 +281,9 @@ fun ItemContextMenu( onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, onDismiss = { dismiss() }, ) - target.album.artistRefs.forEach { artist -> - actions.viewArtist?.let { view -> - MenuItem( - Icons.Rounded.Person, - artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), - ) { - dismiss { view(artist.id) } - } - } + ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::dismissThen) + onInfo?.let { info -> + MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } } } } @@ -298,6 +294,9 @@ fun ItemContextMenu( onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, onDismiss = { dismiss() }, ) + onInfo?.let { info -> + MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } } + } } is MenuTarget.Playlist -> { @@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) { } } +@Composable +private fun ArtistItems( + artists: List, + actions: ItemActions, + onChooseArtists: ((List) -> Unit)?, + dismiss: (() -> Unit) -> Unit, + dismissThen: (() -> Unit) -> Unit, +) { + val view = actions.viewArtist ?: return + val distinct = artists.distinctBy { it.id } + when (distinct.size) { + 0 -> { + Unit + } + + 1 -> { + val artist = distinct.first() + MenuItem( + Icons.Rounded.Person, + artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), + ) { + dismiss { view(artist.id) } + } + } + + else -> { + if (onChooseArtists == null) { + distinct.forEach { artist -> + MenuItem( + Icons.Rounded.Person, + artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist), + ) { + dismiss { view(artist.id) } + } + } + } else { + val fallback = stringResource(R.string.resources_filter_artist) + MenuItem( + Icons.Rounded.Person, + distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } }, + trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) }, + ) { + dismissThen { onChooseArtists(distinct) } + } + } + } + } +} + @Composable private fun DownloadItem( download: SongDownload?, @@ -439,11 +487,19 @@ private fun MenuItem( icon: ImageVector, label: String, tint: Color? = null, + trailing: (@Composable () -> Unit)? = null, onClick: () -> Unit, ) { ListItem( - headlineContent = { Text(label) }, + headlineContent = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) }, + trailingContent = trailing, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable(onClick = onClick), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/LibraryInfoDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/LibraryInfoDialog.kt new file mode 100644 index 00000000..42d6524b --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/LibraryInfoDialog.kt @@ -0,0 +1,129 @@ +package com.subtracks.ui.components + +import android.content.Context +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.subtracks.R +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRef +import com.subtracks.data.model.ListDownloadStatus + +private const val ARTIST_SEPARATOR = " \u00B7 " + +@Composable +fun AlbumInfoDialog( + album: Album, + download: ListDownloadStatus?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.info)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + InfoRow(stringResource(R.string.resources_sort_by_title), album.name) + InfoRow(stringResource(R.string.info_sort_title), album.sortName) + InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names()) + InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort) + InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString()) + InfoRow(stringResource(R.string.resources_filter_genre), album.genre) + InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString()) + download?.takeIf { it.total > 0 }?.let { status -> + InfoRow( + stringResource(R.string.status_downloaded), + stringResource(R.string.info_downloaded_count, status.downloaded, status.total), + ) + } + InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) }) + InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString()) + InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) }) + InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } }, + ) +} + +@Composable +fun ArtistInfoDialog( + artist: Artist, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.info)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + InfoRow(stringResource(R.string.resources_filter_artist), artist.name) + InfoRow(stringResource(R.string.info_sort_name), artist.sortName) + InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString()) + InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString()) + InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString()) + InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) }) + InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } }, + ) +} + +@Composable +internal fun InfoRow( + label: String, + value: String?, +) { + if (value.isNullOrBlank()) return + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(INFO_LABEL_WIDTH), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} + +private fun List.names(): String? = + map { it.name } + .filter { it.isNotBlank() } + .distinct() + .joinToString(ARTIST_SEPARATOR) + .takeIf { it.isNotEmpty() } + +private fun formatDate( + context: Context, + epochSeconds: Long, +): String = + DateUtils.formatDateTime( + context, + epochSeconds * 1000, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR, + ) + +private val INFO_LABEL_WIDTH = 96.dp diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SongArtist.kt b/app/src/main/kotlin/com/subtracks/ui/components/SongArtist.kt index 18965cad..178fe290 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/SongArtist.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/SongArtist.kt @@ -1,10 +1,16 @@ package com.subtracks.ui.components +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.Song private const val ARTIST_SEPARATOR = " \u00B7 " -internal fun Song.artistSubtitle(): String? { +internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists) + +internal fun artistSubtitle( + artist: String?, + artists: List, +): String? { val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct() if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() } val primary = artist?.takeIf { it in names } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt index feafd8d1..d84938a5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt @@ -4,12 +4,7 @@ import android.text.format.DateUtils import android.text.format.Formatter import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -54,7 +47,14 @@ fun SongInfoDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { InfoRow(stringResource(R.string.resources_sort_by_title), song.title) - InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle()) + InfoRow(stringResource(R.string.info_sort_title), song.sortName) + InfoRow( + stringResource( + if (song.artistRefs.size > 1) R.string.info_artists else R.string.resources_filter_artist, + ), + song.artistSubtitle(), + ) + InfoRow(stringResource(R.string.info_artist_sort), song.artistSort) InfoRow(stringResource(R.string.resources_filter_album), song.album) InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime)) InfoRow( @@ -92,32 +92,6 @@ fun SongInfoDialog( ) } -@Composable -private fun InfoRow( - label: String, - value: String?, -) { - if (value.isNullOrBlank()) return - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.width(INFO_LABEL_WIDTH), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - } -} - -private val INFO_LABEL_WIDTH = 80.dp - @Composable private fun downloadStatus(status: DownloadStatus): String = when (status) { 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 61dd3a61..f064c91f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -110,6 +110,7 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.DownloadProgress import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.prefs.AlbumSort @@ -228,6 +229,7 @@ fun LibraryRoute( onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, + onOpenDownloads: () -> Unit = {}, onOpenSearch: () -> Unit = {}, onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, @@ -258,6 +260,7 @@ fun LibraryRoute( val listQueryState = listTab?.let { viewModel.listQuery(it).collectAsStateWithLifecycle() } val listQuery = listQueryState?.value ?: ListQuery("") val offline by viewModel.offline.collectAsStateWithLifecycle() + val downloadProgress by viewModel.downloadProgress.collectAsStateWithLifecycle() val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle() val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery val searchState = listTab?.let { viewModel.search(it).collectAsStateWithLifecycle() } @@ -315,6 +318,7 @@ fun LibraryRoute( onPlaylistClick = onPlaylistClick, onSync = viewModel::sync, onOpenSettings = onOpenSettings, + onOpenDownloads = onOpenDownloads, onOpenSearch = onOpenSearch, onItemLongClick = { focusManager.clearFocus() @@ -324,6 +328,7 @@ fun LibraryRoute( resetKeys = resetKeys, offline = offline, onExitOffline = { viewModel.setOffline(false) }, + downloadProgress = downloadProgress, sortOptions = sortOptionsFor(selectedTab, playSort), starredSupported = listTab?.supportsStarred == true, onSortChange = { sort -> listTab?.let { viewModel.setListQuery(it, listQuery.copy(sort = sort)) } }, @@ -398,10 +403,12 @@ fun LibraryScreen( onSync: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier, + onOpenDownloads: () -> Unit = {}, artistArt: (Artist) -> ArtistArt = { ArtistArt() }, onOpenSearch: () -> Unit = {}, offline: Boolean = false, onExitOffline: () -> Unit = {}, + downloadProgress: DownloadProgress? = null, onItemLongClick: (MenuTarget) -> Unit = {}, syncing: Boolean = false, refreshing: Boolean = false, @@ -594,6 +601,8 @@ fun LibraryScreen( onOpenSettings = onOpenSettings, offline = offline, onExitOffline = onExitOffline, + downloadProgress = downloadProgress, + onOpenDownloads = onOpenDownloads, artwork = artwork, syncing = syncing, ) @@ -858,6 +867,8 @@ internal fun LibraryTabs( onOpenSettings: () -> Unit, offline: Boolean = false, onExitOffline: () -> Unit = {}, + downloadProgress: DownloadProgress? = null, + onOpenDownloads: () -> Unit = {}, artwork: ArtworkColors?, syncing: Boolean = false, ) { @@ -941,7 +952,14 @@ internal fun LibraryTabs( ) } Spacer(Modifier.weight(1f)) - if (offline) { + if (downloadProgress != null) { + IconButton(onClick = onOpenDownloads) { + DownloadProgressIcon( + progress = downloadProgress, + color = MaterialTheme.colorScheme.primary, + ) + } + } else if (offline) { IconButton(onClick = onExitOffline) { Icon( imageVector = Icons.Rounded.CloudOff, @@ -972,6 +990,35 @@ internal fun LibraryTabs( } } +@Composable +private fun DownloadProgressIcon( + progress: DownloadProgress, + color: Color, +) { + val description = stringResource(R.string.downloads_in_progress) + Box( + modifier = Modifier.size(24.dp).semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + val fraction = progress.fraction + if (fraction != null) { + CircularProgressIndicator( + progress = { fraction }, + color = color, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeWidth = 2.5.dp, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator( + color = color, + strokeWidth = 2.5.dp, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TabButton( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 5c818257..0c914cba 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -9,8 +9,11 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.DownloadProgress +import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist +import com.subtracks.data.model.SongDownload import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.LibraryListTab @@ -115,6 +118,13 @@ class LibraryViewModel( val offline: StateFlow = sourceRepository.offline + val downloadProgress: StateFlow = + combine( + downloadRepository.activeDownloads(), + sourceRepository.activeSourceId(), + ) { rows, sourceId -> downloadProgress(rows, sourceId) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + fun setOffline(enabled: Boolean) = sourceRepository.setOfflineMode(enabled) val syncing: StateFlow = @@ -223,6 +233,21 @@ class LibraryViewModel( } } +internal fun downloadProgress( + rows: List, + sourceId: Long?, +): DownloadProgress? { + if (sourceId == null) return null + val active = rows.filter { it.sourceId == sourceId } + if (active.isEmpty()) return null + val running = active.filter { it.status == DownloadStatus.Running } + if (running.isEmpty() || running.any { it.total <= 0 }) return DownloadProgress(bytes = 0, total = 0) + return DownloadProgress( + bytes = running.sumOf { it.bytes }, + total = running.sumOf { it.total }, + ) +} + internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name internal fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt index 6837dc78..68c5ee04 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -44,6 +44,7 @@ import com.subtracks.R import com.subtracks.data.model.CoverArtRef import com.subtracks.playback.PlaybackState import com.subtracks.ui.components.CoverArt +import com.subtracks.ui.components.artistSubtitle import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.playerSurfaceColor @@ -139,7 +140,7 @@ fun MiniPlayer( modifier = Modifier.basicMarquee(), ) Text( - text = item.artist ?: item.album.orEmpty(), + text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, 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 f38e52ff..6c097b22 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.artistSubtitle import com.subtracks.ui.components.prefetchArtworkRatio import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkSeedCache @@ -576,7 +577,10 @@ fun NowPlayingScreen( } Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) { Text( - text = state.item?.artist.orEmpty(), + text = + state.item + ?.let { artistSubtitle(it.artist, it.artists) } + .orEmpty(), style = subtitleStyle, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 588518bc..1f404734 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB import com.subtracks.data.prefs.ReplayGainMode +import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode import com.subtracks.log.Log @@ -101,6 +102,7 @@ private enum class SettingsDialog { SyncMode, ReplayGain, ReplayGainPreamp, + SortNames, } @Composable @@ -128,6 +130,7 @@ fun SettingsRoute( val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle() val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle() val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle() + val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() SettingsScreen( @@ -143,6 +146,7 @@ fun SettingsRoute( scrobbling = scrobbling, replayGainMode = replayGainMode, replayGainPreamp = replayGainPreamp, + sortNameMode = sortNameMode, keepScreenOn = keepScreenOn, offline = offline, verboseLogging = verboseLogging, @@ -158,6 +162,7 @@ fun SettingsRoute( onScrobblingChange = viewModel::setScrobbling, onReplayGainModeChange = viewModel::setReplayGainMode, onReplayGainPreampChange = viewModel::setReplayGainPreamp, + onSortNameModeChange = viewModel::setSortNameMode, onKeepScreenOnChange = viewModel::setKeepScreenOn, onOfflineChange = viewModel::setOfflineMode, onVerboseLoggingChange = viewModel::setVerboseLogging, @@ -205,6 +210,7 @@ fun SettingsScreen( syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES, replayGainMode: ReplayGainMode = ReplayGainMode.Auto, replayGainPreamp: Float = 0f, + sortNameMode: SortNameMode = SortNameMode.SortName, onSelectSource: (Long) -> Unit, onEditServer: (Long) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit, @@ -217,6 +223,7 @@ fun SettingsScreen( onScrobblingChange: (Boolean) -> Unit, onReplayGainModeChange: (ReplayGainMode) -> Unit = {}, onReplayGainPreampChange: (Float) -> Unit = {}, + onSortNameModeChange: (SortNameMode) -> Unit = {}, onKeepScreenOnChange: (Boolean) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {}, onVerboseLoggingChange: (Boolean) -> Unit = {}, @@ -348,6 +355,15 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { SectionHeader(stringResource(R.string.settings_library_name)) } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) }, + supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.SortNames }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { SectionHeader(stringResource(R.string.settings_music_name)) } item { ListItem( @@ -603,6 +619,14 @@ fun SettingsScreen( ) } + SettingsDialog.SortNames -> { + SortNameModeDialog( + selected = sortNameMode, + onSelect = onSortNameModeChange, + onDismiss = { dialog = null }, + ) + } + null -> { Unit } @@ -756,6 +780,49 @@ private fun SyncModeDialog( ) } +private fun sortNameModeLabel( + resources: Resources, + mode: SortNameMode, +): String = + when (mode) { + SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name) + SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name) + SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin) + } + +@Composable +private fun SortNameModeDialog( + selected: SortNameMode, + onSelect: (SortNameMode) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_sort_names_title)) }, + text = { + ChoiceGroup( + header = stringResource(R.string.settings_sort_names_prompt), + options = SortNameMode.entries.map { it to sortNameModeLabel(resources, it) }, + selected = draft, + onSelect = { draft = it }, + ) + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + @Composable private fun ReplayGainModeDialog( selected: ReplayGainMode, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index d9fbbbba..ab0e28c2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.ReplayGainMode +import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.UserPreferences @@ -76,6 +77,11 @@ class SettingsViewModel( .replayGainPreampDb() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f) + val sortNameMode: StateFlow = + userPreferences + .sortNameMode() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.SortName) + val keepScreenOn: StateFlow = userPreferences .keepScreenOn() @@ -143,4 +149,8 @@ class SettingsViewModel( fun setKeepScreenOn(enabled: Boolean) { viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) } } + + fun setSortNameMode(mode: SortNameMode) { + viewModelScope.launch { userPreferences.setSortNameMode(mode) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9dccf365..8601fef2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ Verbose logging Log extra detail for troubleshooting Version %1$s + Library Playback Send now playing and play counts Scrobble to server @@ -96,6 +97,11 @@ ReplayGain Keep the screen on while now playing is open Keep screen on + Sort names + Choose how names are ordered in album and artist lists. + Use sort names + Use regular names + Non-Latin names only Network Stream quality: Mobile Stream quality: Wi-Fi @@ -170,6 +176,7 @@ Downloaded Close search Offline mode; tap to go online + Downloads in progress; tap to open the download manager Shuffle artist Playing @@ -191,6 +198,18 @@ %1$d Hz Channels Close + Artists + Album artists + Album artist sort + Sort title + Artist sort + Sort name + Albums + Songs + Plays + Last played + Added + %1$d of %2$d Disc %1$d %1$s: Disc %2$d diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index dfe72b5a..0776694b 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -20,6 +20,7 @@ import com.subtracks.data.model.Source import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.PlaylistSort +import com.subtracks.data.prefs.SortNameMode import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After @@ -332,10 +333,18 @@ class LibraryDaoTest { runTest { val orders = listOf( - ALBUM_ORDER_BY_NAME to "index_albums_name", - ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name", - ALBUM_ORDER_BY_ARTIST to "index_albums_artist", - ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist", + "${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name", + "${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name", + "${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain", + "${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_name_plain", + "${albumArtistSortKey(SortNameMode.SortName)}, year, " + + "${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_artist", + "${albumArtistSortKey(SortNameMode.SortName)} DESC, year DESC, " + + "${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_artist", + "${albumArtistSortKey(SortNameMode.Name)}, year, " + + "${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_artist_plain", + "${albumArtistSortKey(SortNameMode.Name)} DESC, year DESC, " + + "${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_artist_plain", ALBUM_ORDER_BY_YEAR to "index_albums_year", ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year", ALBUM_ORDER_BY_ADDED to "index_albums_added", @@ -359,8 +368,10 @@ class LibraryDaoTest { runTest { val orders = listOf( - ARTIST_ORDER_BY_NAME to "index_artists_name", - ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name", + "${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name", + "${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name", + "${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain", + "${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain", ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount", ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount", ARTIST_ORDER_BY_STARRED to "index_artists_starred", @@ -1055,7 +1066,7 @@ class LibraryDaoTest { val plan = plan( "SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " + - "ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0", + "ORDER BY ${albumNameSortKey(SortNameMode.SortName)}, id LIMIT 20 OFFSET 0", ) assertTrue(plan, plan.contains("album_search")) @@ -1320,6 +1331,70 @@ class LibraryDaoTest { ) } + @Test + fun theSortNamePreferenceChoosesTheOrderKey() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"), + artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"), + artist(sourceId, "ar-3", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"), + artist(sourceId, "ar-4", "Zebra", albumCount = 1).copy(sortName = "Aaa"), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "Zzz", year = null, starred = null) + .copy(sortName = "Aaa", albumArtistSort = "Zzz", albumArtist = "Beatles"), + album(sourceId, "al-2", "Aaa", year = null, starred = null) + .copy(sortName = "Zzz", albumArtistSort = "Aaa", albumArtist = "Aaa"), + album(sourceId, "al-3", "Beatles", year = null, starred = null) + .copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Zebra"), + ), + ) + + assertEquals( + listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"), + dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name }, + ) + assertEquals( + listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"), + dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name }, + ) + assertEquals( + listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"), + dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name }, + ) + assertEquals( + listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"), + dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name }, + ) + assertEquals( + listOf("Aaa", "Beatles", "Zzz"), + dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name }, + ) + assertEquals( + listOf("Aaa", "Zzz", "Beatles"), + dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name }, + ) + assertEquals( + listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"), + dao + .artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name)) + .page() + .map { it.name }, + ) + assertEquals( + listOf("Aaa", "Beatles", "Zzz"), + dao + .albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name)) + .page() + .map { it.name }, + ) + } + @Test fun sortNamesAreSearchableInArtistsAlbumsAndSongs() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index a0dff94f..1fd8352b 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -51,6 +51,22 @@ class UserPreferencesTest { file.delete() } + @Test + fun theSortNameModeRoundTrips() = + runTest { + val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() } + val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val prefs = UserPreferences(store) + + assertEquals(SortNameMode.SortName, prefs.sortNameMode().first()) + + prefs.setSortNameMode(SortNameMode.NonLatin) + + assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first()) + + file.delete() + } + @Test fun theSelectedLibraryTabRoundTrips() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt index 9312c99c..5e0615d7 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt @@ -76,6 +76,7 @@ class LibraryRepositoryTest { db, sourceRepository, NetworkServerActionSink(sourceRepository), + fakeUserPreferences(), showMessage = { messages += it.resolve(resources) }, scope = scope, ) diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt index 612b94ea..340c91bb 100644 --- a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt @@ -2,6 +2,7 @@ package com.subtracks.playback import androidx.media3.common.C import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.ArtistRef import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -70,6 +71,21 @@ class MediaItemTest { assertEquals(gain, item.replayGain()) } + @Test + fun theItemCarriesEveryArtist() { + val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")) + + val item = + queueMediaItem( + song.copy(artists = artists), + local = null, + stream = "http://server/stream?s=s1", + transcode = false, + ) + + assertEquals(artists, item.artistRefs()) + } + @Test fun theItemCarriesItsGainContext() { val item = diff --git a/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt index 5d993848..a696c26e 100644 --- a/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/QueueItemTest.kt @@ -1,6 +1,7 @@ package com.subtracks.playback import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.Song import org.junit.Assert.assertEquals import org.junit.Test @@ -57,6 +58,29 @@ class QueueItemTest { assertEquals(true, item.albumContext) } + @Test + fun carriesEveryArtist() { + val song = + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "A • B", + duration = 100, + track = 1, + disc = 1, + starred = null, + artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + ) + + val item = AlbumSongItem(song, coverArt = null).toQueueItem() + + assertEquals(listOf("A", "B"), item.artists.map { it.name }) + } + @Test fun aSongWithoutGainCarriesNoGain() { val item = diff --git a/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt index abe7b874..cc84c547 100644 --- a/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt @@ -1,7 +1,10 @@ package com.subtracks.ui.components +import com.subtracks.data.model.DownloadProgress import com.subtracks.data.model.ListDownloadStatus +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -35,4 +38,14 @@ class DownloadIndicatorTest { fun aFullyDownloadedListIsNotDownloading() { assertFalse(ListDownloadStatus(total = 4, downloaded = 4).isDownloading()) } + + @Test + fun totalDownloadProgressCombinesEveryInFlightTransfer() { + assertEquals(0.4f, DownloadProgress(bytes = 40, total = 100).fraction) + } + + @Test + fun queuedDownloadsWithoutASizeShowAnIndeterminateRing() { + assertNull(DownloadProgress(bytes = 0, total = 0).fraction) + } } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt index 8e878b40..700ff256 100644 --- a/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuArtistTest.kt @@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.Song import com.subtracks.ui.theme.SubtracksTheme import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -44,34 +47,69 @@ class ItemContextMenuArtistTest { ) @Test - fun showsOneEntryPerArtistAndOpensTheChosenOne() { - var opened: String? = null + fun pluralArtistsShareOneEntryThatOpensTheChooser() { + var opened: List? = null composeRule.setContent { SubtracksTheme { var show by remember { mutableStateOf(true) } if (show) { ItemContextMenu( target = MenuTarget.Song(song), - actions = ItemActions(viewArtist = { opened = it }), + actions = ItemActions(viewArtist = {}), onDismiss = { show = false }, + onChooseArtists = { opened = it }, ) } } } - composeRule.onNodeWithText("A").assertIsDisplayed() - composeRule.onNodeWithText("B").assertIsDisplayed() + val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction()) + entry.assertIsDisplayed() + assertTrue( + composeRule + .onAllNodesWithText("A") + .filter(hasClickAction()) + .fetchSemanticsNodes() + .isEmpty(), + ) + assertTrue( + composeRule + .onAllNodesWithText("B") + .filter(hasClickAction()) + .fetchSemanticsNodes() + .isEmpty(), + ) composeRule.mainClock.autoAdvance = false - composeRule.onNodeWithText("B").performClick() + entry.performClick() composeRule.mainClock.advanceTimeBy(1_000) - assertEquals("ar2", opened) + assertEquals(listOf("ar1", "ar2"), opened?.map { it.id }) } @Test - fun longMenusScrollToTheLastArtist() { + fun aSingleArtistOpensDirectly() { var opened: String? = null + composeRule.setContent { + SubtracksTheme { + ItemContextMenu( + target = MenuTarget.Song(song.copy(artists = listOf(ArtistRef("ar1", "A")))), + actions = ItemActions(viewArtist = { opened = it }), + onDismiss = {}, + ) + } + } + + composeRule.mainClock.autoAdvance = false + composeRule.onAllNodesWithText("A").filterToOne(hasClickAction()).performClick() + composeRule.mainClock.advanceTimeBy(1_000) + + assertEquals("ar1", opened) + } + + @Test + fun manyArtistsStillShareOneEntry() { + var opened: List? = null composeRule.setContent { SubtracksTheme { ItemContextMenu( @@ -79,18 +117,23 @@ class ItemContextMenuArtistTest { MenuTarget.Song( song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }), ), - actions = ItemActions(viewArtist = { opened = it }), + actions = ItemActions(viewArtist = {}), onDismiss = {}, + onChooseArtists = { opened = it }, ) } } - composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed() + val entry = + composeRule + .onAllNodesWithText((1..20).joinToString(" · ") { "Artist $it" }) + .filterToOne(hasClickAction()) + entry.assertIsDisplayed() composeRule.mainClock.autoAdvance = false - composeRule.onNodeWithText("Artist 20").performClick() + entry.performClick() composeRule.mainClock.advanceTimeBy(1_000) - assertEquals("ar20", opened) + assertEquals(20, opened?.size) } } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuInfoTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuInfoTest.kt new file mode 100644 index 00000000..97153290 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/ItemContextMenuInfoTest.kt @@ -0,0 +1,86 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ItemContextMenuInfoTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val album = + Album( + sourceId = 1, + id = "al1", + artistId = "ar1", + name = "Album", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + ) + + @Test + fun anAlbumMenuOpensItsInfo() { + var opened: MenuTarget? = null + composeRule.setContent { + SubtracksTheme { + ItemContextMenu( + target = MenuTarget.Album(album), + actions = ItemActions(), + onDismiss = {}, + onInfo = { opened = it }, + ) + } + } + + composeRule.onNodeWithText("Info").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + + assertEquals(album, (opened as? MenuTarget.Album)?.album) + } + + @Test + fun anArtistMenuOpensItsInfo() { + var opened: MenuTarget? = null + composeRule.setContent { + SubtracksTheme { + ItemContextMenu( + target = + MenuTarget.Artist( + Artist( + sourceId = 1, + id = "ar1", + name = "Artist", + albumCount = 1, + starred = null, + ), + ), + actions = ItemActions(), + onDismiss = {}, + onInfo = { opened = it }, + ) + } + } + + composeRule.onNodeWithText("Info").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + + assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/components/LibraryInfoDialogTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/LibraryInfoDialogTest.kt new file mode 100644 index 00000000..e23f1371 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/LibraryInfoDialogTest.kt @@ -0,0 +1,81 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.ArtistRef +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryInfoDialogTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun anAlbumShowsEveryArtistAndBothSortNames() { + composeRule.setContent { + SubtracksTheme { + AlbumInfoDialog( + album = + Album( + sourceId = 1, + id = "al1", + artistId = "ar1", + name = "Album", + albumArtist = "A", + created = 0, + coverArt = null, + genre = "Rock", + year = 1999, + starred = null, + songCount = 12, + artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), + sortName = "Album, The", + albumArtistSort = "A, The", + ), + download = null, + onDismiss = {}, + ) + } + } + + composeRule.onNodeWithText("A · B").assertIsDisplayed() + composeRule.onNodeWithText("Album, The").assertIsDisplayed() + composeRule.onNodeWithText("A, The").assertIsDisplayed() + composeRule.onNodeWithText("1999").assertIsDisplayed() + } + + @Test + fun anArtistShowsItsSortNameAndCounts() { + composeRule.setContent { + SubtracksTheme { + ArtistInfoDialog( + artist = + Artist( + sourceId = 1, + id = "ar1", + name = "The Artist", + albumCount = 4, + starred = null, + songCount = 40, + sortName = "Artist, The", + ), + onDismiss = {}, + ) + } + } + + composeRule.onNodeWithText("Artist, The").assertIsDisplayed() + composeRule.onNodeWithText("4").assertIsDisplayed() + composeRule.onNodeWithText("40").assertIsDisplayed() + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt index 522ec460..1adcde8c 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt @@ -90,7 +90,13 @@ class DownloadsViewModelTest { ) viewModel = DownloadsViewModel( - LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), + LibraryRepository( + db, + sourceRepository, + NetworkServerActionSink(sourceRepository), + fakeUserPreferences(), + scope = repoScope, + ), downloadRepository, controller, "Unknown artist", diff --git a/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt index 94487c29..c4b11024 100644 --- a/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/library/LibraryViewModelTest.kt @@ -1,11 +1,15 @@ package com.subtracks.ui.library import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.DownloadProgress +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.SongDownload import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.PlaylistSort import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith @@ -24,4 +28,39 @@ class LibraryViewModelTest { assertEquals(ArtistSort.Name, ListQuery("LegacyArtistOrder").artistSort()) assertEquals(PlaylistSort.Name, ListQuery("LegacyPlaylistOrder").playlistSort()) } + + @Test + fun downloadProgressOnlyCountsTheActiveSource() { + val rows = listOf(download(1, bytes = 40, total = 100), download(2, bytes = 10, total = 20)) + + assertEquals(DownloadProgress(bytes = 40, total = 100), downloadProgress(rows, sourceId = 1)) + } + + @Test + fun anotherSourcesDownloadsDoNotShowAProgressRing() { + assertNull(downloadProgress(listOf(download(2, bytes = 10, total = 20)), sourceId = 1)) + } + + @Test + fun aTransferWithoutAKnownSizeIsIndeterminate() { + val rows = + listOf( + download(1, bytes = 100, total = 100), + download(1, bytes = 20, total = 0).copy(songId = "s2"), + ) + + assertEquals(DownloadProgress(bytes = 0, total = 0), downloadProgress(rows, sourceId = 1)) + } + + private fun download( + sourceId: Long, + bytes: Long, + total: Long, + ) = SongDownload( + sourceId = sourceId, + songId = "s1", + status = DownloadStatus.Running, + bytes = bytes, + total = total, + ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt index 0df29073..659611de 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt @@ -98,7 +98,7 @@ class NowPlayingRouteTest { artworkFetcher = { ByteArray(0) }, scope = repoScope, ).also { it.start() } - library = LibraryRepository(db, sources, serverActions, scope = repoScope) + library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope) handle = FakePlayerHandle() controller = PlaybackController( diff --git a/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt index 1c9d7793..7629e088 100644 --- a/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/search/SearchViewModelTest.kt @@ -90,7 +90,13 @@ class SearchViewModelTest { ) viewModel = SearchViewModel( - LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), + LibraryRepository( + db, + sourceRepository, + NetworkServerActionSink(sourceRepository), + fakeUserPreferences(), + scope = repoScope, + ), sourceRepository, controller, ) diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt index eaa48ca1..0b0ade90 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.ui.theme.SubtracksTheme import org.junit.Assert.assertEquals @@ -31,7 +32,7 @@ class SettingsScreenTest { @Test fun theKeepScreenOnRowTurnsTheSettingOn() { var requested: Boolean? = null - renderSettings(keepScreenOn = false) { requested = it } + renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it }) composeRule.onNodeWithText("Keep screen on").performClick() composeRule.waitForIdle() @@ -42,7 +43,7 @@ class SettingsScreenTest { @Test fun theKeepScreenOnRowTurnsTheSettingOff() { var requested: Boolean? = null - renderSettings(keepScreenOn = true) { requested = it } + renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it }) composeRule.onNodeWithText("Keep screen on").performClick() composeRule.waitForIdle() @@ -53,7 +54,7 @@ class SettingsScreenTest { @Test fun theKeepScreenOnSwitchTurnsTheSettingOn() { var requested: Boolean? = null - renderSettings(keepScreenOn = false) { requested = it } + renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it }) composeRule .onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true) @@ -63,9 +64,29 @@ class SettingsScreenTest { assertEquals(true, requested) } + @Test + fun theSortNamesRowChangesTheSortNameMode() { + var requested: SortNameMode? = null + renderSettings( + keepScreenOn = false, + onKeepScreenOnChange = {}, + onSortNameModeChange = { requested = it }, + scrollTo = "Sort names", + ) + + composeRule.onNodeWithText("Sort names").performClick() + composeRule.onNodeWithText("Use regular names").performClick() + composeRule.onNodeWithText("Done").performClick() + composeRule.waitForIdle() + + assertEquals(SortNameMode.Name, requested) + } + private fun renderSettings( keepScreenOn: Boolean, onKeepScreenOnChange: (Boolean) -> Unit, + onSortNameModeChange: (SortNameMode) -> Unit = {}, + scrollTo: String = "Keep screen on", ) { composeRule.setContent { SubtracksTheme { @@ -88,6 +109,7 @@ class SettingsScreenTest { onDownloadOverMeteredChange = {}, onScrobblingChange = {}, onKeepScreenOnChange = onKeepScreenOnChange, + onSortNameModeChange = onSortNameModeChange, onAddServer = {}, onOpenDownloads = {}, onOpenLicenses = {}, @@ -97,7 +119,7 @@ class SettingsScreenTest { } } awaitText("Add server") - composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on")) + composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo)) } private fun awaitText(text: String) {