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) {