diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/23.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/23.json new file mode 100644 index 00000000..be6c53ab --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/23.json @@ -0,0 +1,1577 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "937060d0998f0eba35786a254f285a79", + "entities": [ + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subsonic_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useTokenAuth", + "columnName": "useTokenAuth", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId" + ] + }, + "indices": [ + { + "name": "index_subsonic_sources_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "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": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)" + } + ], + "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`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "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`) VALUES (NEW.`rowid`, NEW.`name`); 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`) VALUES (NEW.`rowid`, NEW.`name`); 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, 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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` 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`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + } + ], + "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`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); 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`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); 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": "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, `genre` TEXT, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "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", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `id` ASC)" + }, + { + "name": "index_songs_genre", + "unique": false, + "columnNames": [ + "sourceId", + "genre", + "played" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`sourceId` ASC, `genre` ASC, `played` DESC)" + } + ], + "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_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`title`, `artist`, tokenize=`trigram`, content=`songs`)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + } + ], + "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`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`); 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`) VALUES (NEW.`rowid`, NEW.`title`, NEW.`artist`); 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, 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" + } + ], + "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" + ] + } + ] + } + ], + "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, '937060d0998f0eba35786a254f285a79')" + ] + } +} \ 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 61255d47..edcee5b4 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -8,6 +8,7 @@ import androidx.room3.Transaction import androidx.room3.Upsert import androidx.room3.paging.PagingSourceDaoReturnTypeConverter import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.Artist import com.subtracks.data.model.Disc import com.subtracks.data.model.DiscKey @@ -237,6 +238,92 @@ interface LibraryDao { @Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL)") fun hasAlbumPlayed(sourceId: Long): Flow + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " + + "ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun recentlyPlayedAlbums( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " + + "ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun recentlyPlayedArtists( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " + + "ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun mostPlayedAlbums( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " + + "ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun mostPlayedArtists( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId " + + "ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun recentlyAddedAlbums( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " + + "ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit", + ) + fun rediscoverAlbums( + sourceId: Long, + cutoff: Long, + limit: Int, + ): Flow> + + @Query( + "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 songs.starred IS NOT NULL " + + "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit", + ) + fun recentlyStarredSongs( + sourceId: Long, + limit: Int, + ): Flow> + + @Query( + "SELECT genre FROM (" + + "SELECT song_genres.genre AS genre, songs.played AS played FROM song_genres " + + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + + "WHERE song_genres.sourceId = :sourceId " + + "UNION ALL " + + "SELECT songs.genre AS genre, songs.played AS played FROM songs " + + "WHERE songs.sourceId = :sourceId AND songs.genre IS NOT NULL AND songs.genre != '' " + + "AND NOT EXISTS (SELECT 1 FROM song_genres " + + "WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id)" + + ") GROUP BY genre ORDER BY MAX(played) DESC NULLS LAST, genre COLLATE NOCASE", + ) + fun genresByRecentPlay(sourceId: Long): Flow> + + @Query( + "SELECT DISTINCT (year / 10) * 10 AS decade FROM albums " + + "WHERE sourceId = :sourceId AND year IS NOT NULL AND year > 0 ORDER BY decade ASC", + ) + fun decades(sourceId: Long): Flow> + @Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id") suspend fun setArtistStar( sourceId: Long, @@ -585,6 +672,64 @@ interface LibraryDao { downloadedFilter: Int = 0, ): PagingSource + @Query("SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff ORDER BY played ASC, name COLLATE NOCASE, id") + fun albumsByRediscover( + sourceId: Long, + starredFilter: Int, + search: String, + cutoff: Long, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") + fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") + fun homeMostPlayedAlbums(sourceId: Long): PagingSource + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id") + fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource + + @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id") + fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource + + @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id") + fun homeMostPlayedArtists(sourceId: Long): PagingSource + + @Query( + "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 songs.starred IS NOT NULL " + + "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id", + ) + fun starredSongs(sourceId: Long): PagingSource + + @Query( + "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 (" + + "EXISTS (SELECT 1 FROM song_genres " + + "WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id " + + "AND song_genres.genre = :genre) " + + "OR (songs.genre = :genre AND NOT EXISTS (SELECT 1 FROM song_genres " + + "WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id))) " + + "ORDER BY songs.title COLLATE NOCASE, songs.id", + ) + fun songsByGenre( + sourceId: Long, + genre: String, + ): PagingSource + + @Query( + "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + + "ORDER BY year DESC, name COLLATE NOCASE, id", + ) + fun albumsByDecade( + sourceId: Long, + start: Long, + end: Long, + ): PagingSource + @Query(PLAYLIST_SONGS_SQL) fun playlistSongs( sourceId: Long, 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 d0ed6384..c608afdf 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -324,6 +324,21 @@ val MIGRATION_21_22 = } } +val MIGRATION_22_23 = + object : Migration(22, 23) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " + + "(`sourceId` ASC, `starred` DESC, `id` ASC)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `songs` " + + "(`sourceId` ASC, `genre` ASC, `played` DESC)", + ) + createSearchIndex(connection, "song_search", "songs", listOf("title", "artist")) + } + } + // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. private suspend fun createSearchIndex( connection: SQLiteConnection, @@ -379,4 +394,5 @@ val MIGRATIONS: Array = MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, + MIGRATION_22_23, ) 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 cff09a43..70e25df8 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -19,6 +19,7 @@ import com.subtracks.data.model.QueueKindConverter import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongGenre +import com.subtracks.data.model.SongSearch import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.UpNextEntry @@ -37,13 +38,14 @@ import com.subtracks.data.model.UpNextEntry PlaylistSong::class, Song::class, SongGenre::class, + SongSearch::class, QueueEntry::class, UpNextEntry::class, PlaybackCursor::class, ArtworkSeed::class, SongDownload::class, ], - version = 22, + version = 23, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 11ac7677..43820d7e 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -241,6 +241,16 @@ data class PlaylistSong( name = "index_songs_sourceId_albumId_order", value = ["sourceId", "albumId", "disc", "track", "id"], ), + Index( + name = "index_songs_starred", + value = ["sourceId", "starred", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC], + ), + Index( + name = "index_songs_genre", + value = ["sourceId", "genre", "played"], + orders = [Index.Order.ASC, Index.Order.ASC, Index.Order.DESC], + ), ], ) data class Song( @@ -422,6 +432,14 @@ data class PlaylistSearch( val name: String, ) +@Entity(tableName = "song_search") +@Fts5(contentEntity = Song::class, tokenizer = FtsOptions.TOKENIZER_TRIGRAM) +data class SongSearch( + @PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0, + val title: String, + val artist: String?, +) + class QueueKindConverter { @ColumnTypeConverter fun fromQueueKind(kind: QueueKind): String = kind.name 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 bd8a1fde..b915e228 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -6,6 +6,7 @@ import androidx.paging.PagingData import androidx.paging.PagingSource import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.Artist import com.subtracks.data.model.Disc import com.subtracks.data.model.Playlist @@ -42,6 +43,72 @@ class LibraryRepository( fun hasAlbumPlayed(sourceId: Long): Flow = db.libraryDao().hasAlbumPlayed(sourceId) + fun recentlyPlayedAlbums( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().recentlyPlayedAlbums(sourceId, limit) + + fun recentlyPlayedArtists( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().recentlyPlayedArtists(sourceId, limit) + + fun mostPlayedAlbums( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().mostPlayedAlbums(sourceId, limit) + + fun mostPlayedArtists( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().mostPlayedArtists(sourceId, limit) + + fun recentlyAddedAlbums( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().recentlyAddedAlbums(sourceId, limit) + + fun rediscoverAlbums( + sourceId: Long, + cutoff: Long, + limit: Int, + ): Flow> = db.libraryDao().rediscoverAlbums(sourceId, cutoff, limit) + + fun recentlyStarredSongs( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().recentlyStarredSongs(sourceId, limit) + + fun genresByRecentPlay(sourceId: Long): Flow> = db.libraryDao().genresByRecentPlay(sourceId) + + fun decades(sourceId: Long): Flow> = db.libraryDao().decades(sourceId) + + fun rediscoverAlbumsPage(sourceId: Long): Flow> = + pager(40) { db.libraryDao().albumsByRediscover(sourceId, starredFilter = 0, search = "", cutoff = rediscoverCutoff()) } + + fun starredSongs(sourceId: Long): Flow> = pager(60) { db.libraryDao().starredSongs(sourceId) } + + fun songsByGenre( + sourceId: Long, + genre: String, + ): Flow> = pager(60) { db.libraryDao().songsByGenre(sourceId, genre) } + + fun albumsByDecade( + sourceId: Long, + decade: Long, + ): Flow> = pager(40) { db.libraryDao().albumsByDecade(sourceId, decade, decade + 10) } + + fun homeRecentlyPlayedAlbums(sourceId: Long): Flow> = pager(40) { db.libraryDao().homeRecentlyPlayedAlbums(sourceId) } + + fun homeMostPlayedAlbums(sourceId: Long): Flow> = pager(40) { db.libraryDao().homeMostPlayedAlbums(sourceId) } + + fun homeRecentlyAddedAlbums(sourceId: Long): Flow> = pager(40) { db.libraryDao().homeRecentlyAddedAlbums(sourceId) } + + fun homeRecentlyPlayedArtists(sourceId: Long): Flow> = + pager(60) { db.libraryDao().homeRecentlyPlayedArtists(sourceId) } + + fun homeMostPlayedArtists(sourceId: Long): Flow> = pager(60) { db.libraryDao().homeMostPlayedArtists(sourceId) } + fun albums( sourceId: Long, sort: AlbumSort, @@ -365,3 +432,7 @@ class LibraryRepository( pagingSourceFactory = source, ).flow } + +const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 + +fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index a7c0c0fa..29c9fced 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -30,6 +30,8 @@ import com.subtracks.playback.PlayerConnection import com.subtracks.playback.Scrobbler import com.subtracks.ui.RootViewModel import com.subtracks.ui.downloads.DownloadsViewModel +import com.subtracks.ui.home.HomeListViewModel +import com.subtracks.ui.home.HomeViewModel import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel @@ -108,6 +110,8 @@ fun appModule( } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { HomeViewModel(get(), get(), get(), get()) } + viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { DownloadsViewModel(get(), get(), get()) } viewModel { params -> AddSourceViewModel(get(), get(), get(), params.getOrNull()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index af943900..6d9d5de7 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -205,6 +205,11 @@ class PlaybackController( startOrdinal: Long, ) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal) + fun playSong( + sourceId: Long, + songId: String, + ) = playContext(queueRepository.songEntry(sourceId, songId), 0) + fun playPlaylist( sourceId: Long, playlistId: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 35d928de..4c61d0c3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -69,6 +69,9 @@ import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.SongInfoDialog import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.downloads.DownloadsRoute +import com.subtracks.ui.home.HomeListRequest +import com.subtracks.ui.home.HomeListRoute +import com.subtracks.ui.home.HomeSection import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.LibraryRoute @@ -117,6 +120,9 @@ private object Routes { const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}" const val PLAYLIST_DETAIL = "playlist/{playlistId}" + const val HOME_SECTION = "home/more/{section}" + const val HOME_GENRE = "home/genre/{genre}" + const val HOME_DECADE = "home/decade/{decade}" fun album( id: String, @@ -131,6 +137,12 @@ private object Routes { fun playlist(id: String) = "playlist/${Uri.encode(id)}" fun editServer(id: Long) = "edit-server/$id" + + fun homeSection(section: HomeSection) = "home/more/${section.name}" + + fun homeGenre(genre: String) = "home/genre/${Uri.encode(genre)}" + + fun homeDecade(decade: Long) = "home/decade/$decade" } private val DETAIL_ROUTES = @@ -351,6 +363,9 @@ private fun MainNavigation() { onViewArtist = { artistId -> navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) }, + onHomeMore = { section -> navController.navigate(Routes.homeSection(section)) }, + onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) }, + onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) }, contextMenuHost = contextMenuHost, setStar = libraryRepository::star, bottomInset = bottomInset, @@ -439,6 +454,78 @@ private fun MainNavigation() { setStar = libraryRepository::star, ) } + composable( + route = Routes.HOME_SECTION, + arguments = listOf(navArgument("section") { type = NavType.StringType }), + ) { entry -> + val section = HomeSection.fromRoute(entry.arguments?.getString("section")) ?: return@composable + HomeListRoute( + request = HomeListRequest(title = section.title, section = section), + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> + navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) + }, + onArtistClick = { artist -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + }, + onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, + onViewArtist = { artistId -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) + }, + onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) }, + onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) }, + contextMenuHost = contextMenuHost, + setStar = libraryRepository::star, + ) + } + composable( + route = Routes.HOME_GENRE, + arguments = listOf(navArgument("genre") { type = NavType.StringType }), + ) { entry -> + val genre = entry.arguments?.getString("genre").orEmpty() + HomeListRoute( + request = HomeListRequest(title = genre, genre = genre), + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> + navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) + }, + onArtistClick = { artist -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + }, + onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, + onViewArtist = { artistId -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) + }, + onGenreClick = { value -> navController.navigate(Routes.homeGenre(value)) }, + onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) }, + contextMenuHost = contextMenuHost, + setStar = libraryRepository::star, + ) + } + composable( + route = Routes.HOME_DECADE, + arguments = listOf(navArgument("decade") { type = NavType.LongType }), + ) { entry -> + val decade = entry.arguments?.getLong("decade") ?: return@composable + HomeListRoute( + request = HomeListRequest(title = "${decade}s", decade = decade), + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> + navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) + }, + onArtistClick = { artist -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt)) + }, + onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) }, + onViewArtist = { artistId -> + navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId)) + }, + onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) }, + onDecadeClick = { value -> navController.navigate(Routes.homeDecade(value)) }, + contextMenuHost = contextMenuHost, + setStar = libraryRepository::star, + ) + } composable(Routes.ADD_SERVER) { AddSourceRoute( onSaved = { navController.popBackStack() }, diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt new file mode 100644 index 00000000..03ba3255 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -0,0 +1,252 @@ +package com.subtracks.ui.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Song +import com.subtracks.data.source.StarType +import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.ItemActions +import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.library.AlbumsContent +import com.subtracks.ui.library.ArtistsContent +import com.subtracks.ui.library.SongRow +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun HomeListRoute( + request: HomeListRequest, + onBack: () -> Unit, + onAlbumClick: (Album) -> Unit, + onArtistClick: (Artist) -> Unit, + onViewAlbum: (String) -> Unit, + onViewArtist: (String) -> Unit, + onGenreClick: (String) -> Unit, + onDecadeClick: (Long) -> Unit, + contextMenuHost: ContextMenuHost? = null, + setStar: (StarType, String, Boolean) -> Unit, + viewModel: HomeListViewModel = koinViewModel { parametersOf(request) }, + playbackController: PlaybackController = koinInject(), +) { + val playback by playbackController.state.collectAsStateWithLifecycle() + val genresState = if (request.section == HomeSection.Genres) viewModel.genres.collectAsStateWithLifecycle() else null + val genres = genresState?.value.orEmpty() + val decadesState = if (request.section == HomeSection.Decades) viewModel.decades.collectAsStateWithLifecycle() else null + val decades = decadesState?.value.orEmpty() + val actions = + ItemActions( + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + setStar = setStar, + viewAlbum = onViewAlbum, + viewArtist = onViewArtist, + ) + HomeListScreen( + request = request, + albums = viewModel.albums.collectAsLazyPagingItems(), + artists = viewModel.artists.collectAsLazyPagingItems(), + songs = viewModel.songs.collectAsLazyPagingItems(), + genres = genres, + decades = decades, + coverArt = viewModel::coverArt, + playingSongId = playback.item?.id, + onBack = onBack, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + onSongClick = viewModel::play, + onLongClick = { contextMenuHost?.show(it, actions) }, + onGenreClick = onGenreClick, + onDecadeClick = onDecadeClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeListScreen( + request: HomeListRequest, + albums: LazyPagingItems, + artists: LazyPagingItems, + songs: LazyPagingItems, + genres: List = emptyList(), + decades: List = emptyList(), + coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, + playingSongId: String? = null, + onBack: () -> Unit = {}, + onAlbumClick: (Album) -> Unit = {}, + onArtistClick: (Artist) -> Unit = {}, + onSongClick: (Song) -> Unit = {}, + onLongClick: (MenuTarget) -> Unit = {}, + onGenreClick: (String) -> Unit = {}, + onDecadeClick: (Long) -> Unit = {}, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(request.title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when (request.section) { + HomeSection.Genres -> { + PlainList( + items = genres, + label = { it }, + onClick = onGenreClick, + modifier = Modifier.padding(padding), + ) + } + + HomeSection.Decades -> { + PlainList( + items = decades, + label = { "${it}s" }, + onClick = onDecadeClick, + modifier = Modifier.padding(padding), + ) + } + + HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> { + ArtistsContent( + items = artists, + coverArt = coverArt, + bottomInset = 0.dp, + onArtistClick = onArtistClick, + onLongClick = onLongClick, + modifier = Modifier.padding(padding), + ) + } + + HomeSection.RecentlyStarredSongs -> { + SongsList( + songs = songs, + coverArt = coverArt, + playingSongId = playingSongId, + onSongClick = onSongClick, + onLongClick = onLongClick, + modifier = Modifier.padding(padding), + ) + } + + HomeSection.RecentlyPlayedAlbums, + HomeSection.MostPlayedAlbums, + HomeSection.RecentlyAddedAlbums, + HomeSection.Rediscover, + null, + -> { + if (request.genre != null) { + SongsList( + songs = songs, + coverArt = coverArt, + playingSongId = playingSongId, + onSongClick = onSongClick, + onLongClick = onLongClick, + modifier = Modifier.padding(padding), + ) + } else { + AlbumsContent( + items = albums, + coverArt = coverArt, + bottomInset = 0.dp, + onAlbumClick = onAlbumClick, + onLongClick = onLongClick, + modifier = Modifier.padding(padding), + ) + } + } + } + } +} + +@Composable +private fun PlainList( + items: List, + label: (T) -> String, + onClick: (T) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier.fillMaxSize()) { + items(items) { item -> + ListItem( + headlineContent = { Text(label(item)) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { onClick(item) }, + ) + } + } +} + +@Composable +private fun SongsList( + songs: LazyPagingItems, + coverArt: (String?, Boolean) -> CoverArtRef?, + playingSongId: String?, + onSongClick: (Song) -> Unit, + onLongClick: (MenuTarget) -> Unit, + modifier: Modifier = Modifier, +) { + if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) { + LoadingState(modifier) + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> + val item = songs[index] + if (item != null) { + SongRow( + song = item.song, + coverArtId = item.coverArt, + coverArt = coverArt, + isPlaying = item.song.id == playingSongId, + durationSeconds = item.song.duration, + modifier = + Modifier.combinedClickable( + onClick = { onSongClick(item.song) }, + onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, + ), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt new file mode 100644 index 00000000..816d456f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt @@ -0,0 +1,116 @@ +package com.subtracks.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Song +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn + +data class HomeListRequest( + val title: String, + val section: HomeSection? = null, + val genre: String? = null, + val decade: Long? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeListViewModel( + private val libraryRepository: LibraryRepository, + private val sourceRepository: SourceRepository, + private val playbackController: PlaybackController, + val request: HomeListRequest, +) : ViewModel() { + private val sourceId = libraryRepository.activeSourceId.filterNotNull() + + val albums: Flow> = + sourceId + .flatMapLatest { id -> + when { + request.decade != null -> { + libraryRepository.albumsByDecade(id, request.decade) + } + + request.section == HomeSection.RecentlyPlayedAlbums -> { + libraryRepository.homeRecentlyPlayedAlbums(id) + } + + request.section == HomeSection.MostPlayedAlbums -> { + libraryRepository.homeMostPlayedAlbums(id) + } + + request.section == HomeSection.RecentlyAddedAlbums -> { + libraryRepository.homeRecentlyAddedAlbums(id) + } + + request.section == HomeSection.Rediscover -> { + libraryRepository.rediscoverAlbumsPage(id) + } + + else -> { + flowOf(PagingData.empty()) + } + } + }.cachedIn(viewModelScope) + + val artists: Flow> = + sourceId + .flatMapLatest { id -> + when (request.section) { + HomeSection.RecentlyPlayedArtists -> { + libraryRepository.homeRecentlyPlayedArtists(id) + } + + HomeSection.MostPlayedArtists -> { + libraryRepository.homeMostPlayedArtists(id) + } + + else -> { + flowOf(PagingData.empty()) + } + } + }.cachedIn(viewModelScope) + + val songs: Flow> = + sourceId + .flatMapLatest { id -> + when { + request.genre != null -> libraryRepository.songsByGenre(id, request.genre) + request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id) + else -> flowOf(PagingData.empty()) + } + }.cachedIn(viewModelScope) + + val genres: StateFlow> = + sourceId + .flatMapLatest { libraryRepository.genresByRecentPlay(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val decades: StateFlow> = + sourceId + .flatMapLatest { libraryRepository.decades(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun play(song: Song) { + viewModelScope.playSongInContext(libraryRepository, playbackController, song) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt new file mode 100644 index 00000000..c5adca50 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -0,0 +1,433 @@ +package com.subtracks.ui.home + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Song +import com.subtracks.data.source.StarType +import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.CoverArt +import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.ItemActions +import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.library.SongRow +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +enum class HomeSection( + val title: String, +) { + RecentlyPlayedAlbums("Recently played"), + RecentlyPlayedArtists("Recently played artists"), + MostPlayedAlbums("Most played"), + MostPlayedArtists("Most played artists"), + Genres("Genres"), + Decades("Decades"), + RecentlyStarredSongs("Recently starred"), + RecentlyAddedAlbums("Recently added"), + Rediscover("On repeat"), + ; + + companion object { + fun fromRoute(value: String?): HomeSection? = entries.firstOrNull { it.name == value } + } +} + +@Composable +fun HomeRoute( + onAlbumClick: (Album) -> Unit, + onArtistClick: (Artist) -> Unit, + onViewAlbum: (String) -> Unit, + onViewArtist: (String) -> Unit, + onMore: (HomeSection) -> Unit, + onGenreClick: (String) -> Unit, + onDecadeClick: (Long) -> Unit, + contextMenuHost: ContextMenuHost? = null, + setStar: (StarType, String, Boolean) -> Unit, + topInset: Dp, + bottomInset: Dp, + viewModel: HomeViewModel = koinViewModel(), + playbackController: PlaybackController = koinInject(), +) { + val feed by viewModel.feed.collectAsStateWithLifecycle() + val playback by playbackController.state.collectAsStateWithLifecycle() + val actions = + ItemActions( + playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, + addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + setStar = setStar, + viewAlbum = onViewAlbum, + viewArtist = onViewArtist, + ) + HomeScreen( + feed = feed, + coverArt = viewModel::coverArt, + playingSongId = playback.item?.id, + topInset = topInset, + bottomInset = bottomInset, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + onSongClick = viewModel::play, + onLongClick = { contextMenuHost?.show(it, actions) }, + onMore = onMore, + onGenreClick = onGenreClick, + onDecadeClick = onDecadeClick, + onSync = viewModel::sync, + ) +} + +@Composable +fun HomeScreen( + feed: HomeFeed, + coverArt: (String?, Boolean) -> CoverArtRef?, + playingSongId: String?, + topInset: Dp = 0.dp, + bottomInset: Dp = 0.dp, + onAlbumClick: (Album) -> Unit = {}, + onArtistClick: (Artist) -> Unit = {}, + onSongClick: (Song) -> Unit = {}, + onLongClick: (MenuTarget) -> Unit = {}, + onMore: (HomeSection) -> Unit = {}, + onGenreClick: (String) -> Unit = {}, + onDecadeClick: (Long) -> Unit = {}, + onSync: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + if (feed.loading) { + LoadingState(modifier) + return + } + if (feed.isEmpty()) { + EmptyState( + text = "Nothing to show yet.\nSync with your server to fill your library.", + modifier = modifier, + actionLabel = "Sync", + onAction = onSync, + ) + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(top = topInset + 8.dp, bottom = bottomInset + 24.dp), + ) { + albumRow( + HomeSection.RecentlyPlayedAlbums, + feed.recentlyPlayedAlbums, + coverArt, + onAlbumClick, + onLongClick, + onMore, + ) + artistRow( + HomeSection.RecentlyPlayedArtists, + feed.recentlyPlayedArtists, + coverArt, + onArtistClick, + onLongClick, + onMore, + ) + albumRow( + HomeSection.MostPlayedAlbums, + feed.mostPlayedAlbums, + coverArt, + onAlbumClick, + onLongClick, + onMore, + ) + artistRow( + HomeSection.MostPlayedArtists, + feed.mostPlayedArtists, + coverArt, + onArtistClick, + onLongClick, + onMore, + ) + genreBlock(HomeSection.Genres, feed.genres, onGenreClick, onMore) + decadeRow(HomeSection.Decades, feed.decades, onDecadeClick, onMore) + starredSongs(HomeSection.RecentlyStarredSongs, feed.recentlyStarredSongs, coverArt, playingSongId, onSongClick, onLongClick, onMore) + albumRow( + HomeSection.RecentlyAddedAlbums, + feed.recentlyAddedAlbums, + coverArt, + onAlbumClick, + onLongClick, + onMore, + ) + albumRow( + HomeSection.Rediscover, + feed.rediscoverAlbums, + coverArt, + onAlbumClick, + onLongClick, + onMore, + ) + } +} + +private fun HomeFeed.isEmpty(): Boolean = + recentlyPlayedAlbums.isEmpty() && + recentlyPlayedArtists.isEmpty() && + mostPlayedAlbums.isEmpty() && + mostPlayedArtists.isEmpty() && + genres.isEmpty() && + decades.isEmpty() && + recentlyStarredSongs.isEmpty() && + recentlyAddedAlbums.isEmpty() && + rediscoverAlbums.isEmpty() + +@Composable +private fun HomeSectionHeader( + title: String, + onMore: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 20.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + if (onMore != null) { + TextButton(onClick = onMore) { + Text("More") + } + } + } +} + +private val TILE_SIZE = 132.dp +private val ARTIST_TILE_SIZE = 104.dp +private val TILE_SPACING = 8.dp + +private fun LazyListScope.albumRow( + section: HomeSection, + albums: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onAlbumClick: (Album) -> Unit, + onLongClick: (MenuTarget) -> Unit, + onMore: (HomeSection) -> Unit, +) { + if (albums.isEmpty()) return + item(key = "${section.name}-header") { + HomeSectionHeader(section.title) { onMore(section) } + } + item(key = "${section.name}-row") { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(TILE_SPACING), + ) { + items(albums, key = { it.id }) { album -> + val ref = coverArt(album.coverArt, true) + AlbumTile( + album = album, + ref = ref, + onClick = { onAlbumClick(album) }, + onLongClick = { onLongClick(MenuTarget.Album(album, ref)) }, + ) + } + } + } +} + +private fun LazyListScope.artistRow( + section: HomeSection, + artists: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onArtistClick: (Artist) -> Unit, + onLongClick: (MenuTarget) -> Unit, + onMore: (HomeSection) -> Unit, +) { + if (artists.isEmpty()) return + item(key = "${section.name}-header") { + HomeSectionHeader(section.title) { onMore(section) } + } + item(key = "${section.name}-row") { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(TILE_SPACING), + ) { + items(artists, key = { it.id }) { artist -> + val ref = coverArt(artist.coverArt, true) + ArtistTile( + artist = artist, + ref = ref, + onClick = { onArtistClick(artist) }, + onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) }, + ) + } + } + } +} + +private fun LazyListScope.genreBlock( + section: HomeSection, + genres: List, + onGenreClick: (String) -> Unit, + onMore: (HomeSection) -> Unit, +) { + if (genres.isEmpty()) return + item(key = "${section.name}-header") { + HomeSectionHeader(section.title) { onMore(section) } + } + item(key = "${section.name}-row") { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(TILE_SPACING), + ) { + items(genres.chunked(GENRE_COLUMN_HEIGHT), key = { it.first() }) { column -> + Column(verticalArrangement = Arrangement.spacedBy(TILE_SPACING)) { + column.forEach { genre -> + AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) }) + } + } + } + } + } +} + +private fun LazyListScope.decadeRow( + section: HomeSection, + decades: List, + onDecadeClick: (Long) -> Unit, + onMore: (HomeSection) -> Unit, +) { + if (decades.isEmpty()) return + item(key = "${section.name}-header") { + HomeSectionHeader(section.title) { onMore(section) } + } + item(key = "${section.name}-row") { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(TILE_SPACING), + ) { + items(decades, key = { it }) { decade -> + AssistChip(onClick = { onDecadeClick(decade) }, label = { Text("${decade}s") }) + } + } + } +} + +private fun LazyListScope.starredSongs( + section: HomeSection, + songs: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + playingSongId: String?, + onSongClick: (Song) -> Unit, + onLongClick: (MenuTarget) -> Unit, + onMore: (HomeSection) -> Unit, +) { + if (songs.isEmpty()) return + item(key = "${section.name}-header") { + HomeSectionHeader(section.title) { onMore(section) } + } + items(songs, key = { it.song.id }) { item -> + SongRow( + song = item.song, + coverArtId = item.coverArt, + coverArt = coverArt, + isPlaying = item.song.id == playingSongId, + durationSeconds = item.song.duration, + modifier = + Modifier.combinedClickable( + onClick = { onSongClick(item.song) }, + onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, + ), + ) + } +} + +private const val GENRE_COLUMN_HEIGHT = 3 + +@Composable +private fun AlbumTile( + album: Album, + ref: CoverArtRef?, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Column( + modifier = Modifier.width(TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick), + ) { + CoverArt( + ref = ref, + name = album.name, + modifier = Modifier.size(TILE_SIZE).clip(RoundedCornerShape(4.dp)), + ) + Spacer(Modifier.height(6.dp)) + Text( + text = album.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = album.albumArtist.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ArtistTile( + artist: Artist, + ref: CoverArtRef?, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Column( + modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CoverArt( + ref = ref, + name = artist.name, + modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape), + ) + Spacer(Modifier.height(6.dp)) + Text( + text = artist.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt new file mode 100644 index 00000000..977619da --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -0,0 +1,164 @@ +package com.subtracks.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.Song +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.repo.rediscoverCutoff +import com.subtracks.data.sync.SyncManager +import com.subtracks.data.sync.SyncStatus +import com.subtracks.playback.PlaybackController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +const val HOME_ROW_LIMIT = 10 +const val HOME_STARRED_LIMIT = 5 + +data class HomeFeed( + val recentlyPlayedAlbums: List = emptyList(), + val recentlyPlayedArtists: List = emptyList(), + val mostPlayedAlbums: List = emptyList(), + val mostPlayedArtists: List = emptyList(), + val genres: List = emptyList(), + val decades: List = emptyList(), + val recentlyStarredSongs: List = emptyList(), + val recentlyAddedAlbums: List = emptyList(), + val rediscoverAlbums: List = emptyList(), + val loading: Boolean = false, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModel( + private val libraryRepository: LibraryRepository, + private val sourceRepository: SourceRepository, + private val syncManager: SyncManager, + private val playbackController: PlaybackController, +) : ViewModel() { + val syncing: StateFlow = + syncManager.status + .map { it == SyncStatus.Running } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + val feed: StateFlow = + libraryRepository.activeSourceId + .filterNotNull() + .flatMapLatest { sourceId -> + val plays = + combine( + libraryRepository.recentlyPlayedAlbums(sourceId, HOME_ROW_LIMIT), + libraryRepository.recentlyPlayedArtists(sourceId, HOME_ROW_LIMIT), + libraryRepository.mostPlayedAlbums(sourceId, HOME_ROW_LIMIT), + libraryRepository.mostPlayedArtists(sourceId, HOME_ROW_LIMIT), + ) { albums, artists, frequentAlbums, frequentArtists -> + PlayRows(albums, artists, frequentAlbums, frequentArtists) + } + val discovery = + combine( + libraryRepository.genresByRecentPlay(sourceId), + libraryRepository.decades(sourceId), + libraryRepository.recentlyStarredSongs(sourceId, HOME_STARRED_LIMIT), + libraryRepository.recentlyAddedAlbums(sourceId, HOME_ROW_LIMIT), + libraryRepository.rediscoverAlbums(sourceId, rediscoverCutoff(), HOME_ROW_LIMIT), + ) { genres, decades, starred, added, rediscover -> + Discovery(genres, decades, starred, added, rediscover) + } + val hasPlayData = + combine( + libraryRepository.hasAlbumPlayCount(sourceId), + libraryRepository.hasAlbumPlayed(sourceId), + ) { frequent, recent -> frequent || recent } + combine(plays, discovery, hasPlayData) { rows, found, played -> + buildHomeFeed( + recentAlbums = rows.recentAlbums, + recentArtists = rows.recentArtists, + frequentAlbums = rows.frequentAlbums, + frequentArtists = rows.frequentArtists, + genres = found.genres, + decades = found.decades, + starredSongs = found.starredSongs, + addedAlbums = found.addedAlbums, + rediscover = found.rediscover, + hasPlayData = played, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeFeed(loading = true)) + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun play(song: Song) { + viewModelScope.playSongInContext(libraryRepository, playbackController, song) + } + + fun sync() = syncManager.requestSync() + + private data class PlayRows( + val recentAlbums: List, + val recentArtists: List, + val frequentAlbums: List, + val frequentArtists: List, + ) + + private data class Discovery( + val genres: List, + val decades: List, + val starredSongs: List, + val addedAlbums: List, + val rediscover: List, + ) +} + +internal fun buildHomeFeed( + recentAlbums: List, + recentArtists: List, + frequentAlbums: List, + frequentArtists: List, + genres: List, + decades: List, + starredSongs: List, + addedAlbums: List, + rediscover: List, + hasPlayData: Boolean, +): HomeFeed = + HomeFeed( + recentlyPlayedAlbums = recentAlbums.takeIf { hasPlayData }.orEmpty(), + recentlyPlayedArtists = recentArtists.takeIf { hasPlayData }.orEmpty(), + mostPlayedAlbums = frequentAlbums.takeIf { hasPlayData }.orEmpty(), + mostPlayedArtists = frequentArtists.takeIf { hasPlayData }.orEmpty(), + genres = genres, + decades = decades, + recentlyStarredSongs = starredSongs, + recentlyAddedAlbums = addedAlbums, + rediscoverAlbums = rediscover, + ) + +internal fun CoroutineScope.playSongInContext( + libraryRepository: LibraryRepository, + playbackController: PlaybackController, + song: Song, +) { + launch { + val albumId = song.albumId + if (albumId == null) { + playbackController.playSong(song.sourceId, song.id) + } else { + val ordinal = libraryRepository.albumSongOrdinal(song.sourceId, albumId, song.id) + playbackController.playAlbum(song.sourceId, albumId, ordinal) + } + } +} 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 5664c9be..75a0eaf0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.FilterAltOff +import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.rounded.Search @@ -124,6 +125,8 @@ import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.PendingDownloadDelete import com.subtracks.ui.components.bulkRef import com.subtracks.ui.components.statusBarScrim +import com.subtracks.ui.home.HomeRoute +import com.subtracks.ui.home.HomeSection import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.librarySurfaceColor import com.subtracks.ui.theme.playerSurfaceColor @@ -139,6 +142,7 @@ enum class LibraryTab( val label: String, val icon: ImageVector, ) { + Home("Home", Icons.Rounded.Home), Albums("Albums", Icons.Rounded.Album), Artists("Artists", Icons.Rounded.Person), Playlists("Playlists", Icons.AutoMirrored.Rounded.PlaylistPlay), @@ -150,9 +154,9 @@ data class SortOption( val descendingByDefault: Boolean = false, ) -fun LibraryTab.listTab(): LibraryListTab = LibraryListTab.valueOf(name) +fun LibraryTab.listTab(): LibraryListTab? = LibraryListTab.entries.firstOrNull { it.name == name } -internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Albums +internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Home fun sortOptionsFor( tab: LibraryTab, @@ -188,6 +192,10 @@ fun sortOptionsFor( SortOption(PlaylistSort.Updated.name, "Updated", descendingByDefault = true), ) } + + LibraryTab.Home -> { + emptyList() + } } @OptIn(ExperimentalMaterial3Api::class) @@ -199,6 +207,9 @@ fun LibraryRoute( onOpenSettings: () -> Unit, onViewAlbum: (String) -> Unit, onViewArtist: (String) -> Unit, + onHomeMore: (HomeSection) -> Unit = {}, + onGenreClick: (String) -> Unit = {}, + onDecadeClick: (Long) -> Unit = {}, contextMenuHost: ContextMenuHost? = null, setStar: (StarType, String, Boolean) -> Unit, bottomInset: Dp, @@ -214,24 +225,29 @@ fun LibraryRoute( val listTab = selectedTab.listTab() LaunchedEffect(selectedTab) { if (previousTab != selectedTab) { - viewModel.setSearch(previousTab.listTab(), "") + previousTab.listTab()?.let { viewModel.setSearch(it, "") } previousTabName = selectedTab.name } } - val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle() + val listQueryState = listTab?.let { viewModel.listQuery(it).collectAsStateWithLifecycle() } + val listQuery = listQueryState?.value ?: ListQuery("") val offline by viewModel.offline.collectAsStateWithLifecycle() val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle() val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery - val search by viewModel.search(listTab).collectAsStateWithLifecycle() + val searchState = listTab?.let { viewModel.search(it).collectAsStateWithLifecycle() } + val search = searchState?.value.orEmpty() val resetKeys = - LibraryTab.entries.associateWith { tab -> - val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle() - val term by viewModel.search(tab.listTab()).collectAsStateWithLifecycle() - "${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term" - } + LibraryTab.entries + .mapNotNull { tab -> + val tabList = tab.listTab() ?: return@mapNotNull null + val query by viewModel.listQuery(tabList).collectAsStateWithLifecycle() + val term by viewModel.search(tabList).collectAsStateWithLifecycle() + tab to "${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term" + }.toMap() val playback by playbackController.state.collectAsStateWithLifecycle() val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title) - val downloads by viewModel.downloads(listTab).collectAsStateWithLifecycle() + val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() } + val downloads = downloadsState?.value ?: emptyMap() val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } val itemActions = @@ -275,9 +291,11 @@ fun LibraryRoute( offline = offline, onExitOffline = { viewModel.setOffline(false) }, sortOptions = sortOptionsFor(selectedTab, playSort), - starredSupported = listTab.supportsStarred, - onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) }, - onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) }, + starredSupported = listTab?.supportsStarred == true, + onSortChange = { sort -> listTab?.let { viewModel.setListQuery(it, listQuery.copy(sort = sort)) } }, + onToggleSortDirection = { + listTab?.let { viewModel.setListQuery(it, listQuery.copy(descending = !listQuery.descending)) } + }, onCycleStarred = { val next = when (listQuery.starred) { @@ -285,18 +303,37 @@ fun LibraryRoute( StarredFilter.Starred -> StarredFilter.NotStarred StarredFilter.NotStarred -> StarredFilter.Any } - viewModel.setListQuery(listTab, listQuery.copy(starred = next)) + listTab?.let { viewModel.setListQuery(it, listQuery.copy(starred = next)) } }, onClearFilters = { - viewModel.setListQuery(listTab, listQuery.copy(starred = StarredFilter.Any, downloaded = false)) - viewModel.setSearch(listTab, "") + listTab?.let { + viewModel.setListQuery(it, listQuery.copy(starred = StarredFilter.Any, downloaded = false)) + viewModel.setSearch(it, "") + } }, search = search, - onSearchChange = { viewModel.setSearch(listTab, it) }, - onToggleDownloaded = { viewModel.setListQuery(listTab, listQuery.copy(downloaded = !listQuery.downloaded)) }, + onSearchChange = { value -> listTab?.let { viewModel.setSearch(it, value) } }, + onToggleDownloaded = { + listTab?.let { viewModel.setListQuery(it, listQuery.copy(downloaded = !listQuery.downloaded)) } + }, albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(), artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(), playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(), + homeContent = { topInset, bottomInset -> + HomeRoute( + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + onViewAlbum = onViewAlbum, + onViewArtist = onViewArtist, + onMore = onHomeMore, + onGenreClick = onGenreClick, + onDecadeClick = onDecadeClick, + contextMenuHost = contextMenuHost, + setStar = setStar, + topInset = topInset, + bottomInset = bottomInset, + ) + }, ) pendingDelete?.let { pending -> @@ -343,6 +380,7 @@ fun LibraryScreen( albumDownloads: Map = emptyMap(), artistDownloads: Map = emptyMap(), playlistDownloads: Map = emptyMap(), + homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { val pagerState = @@ -413,6 +451,10 @@ fun LibraryScreen( modifier = Modifier.fillMaxSize(), ) { when (pageTab) { + LibraryTab.Home -> { + homeContent(listTopInset, listBottomInset) + } + LibraryTab.Albums -> { AlbumsContent( albums.collectAsLazyPagingItems(), @@ -518,7 +560,7 @@ fun LibraryScreen( ) } - if (!searchActive) { + if (!searchActive && selectedTab != LibraryTab.Home) { FloatingActionButton( onClick = { showOptions = true }, containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh, diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt new file mode 100644 index 00000000..23d9c7cd --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt @@ -0,0 +1,435 @@ +package com.subtracks.data.db + +import android.content.Context +import androidx.paging.PagingSource +import androidx.room3.Room +import androidx.room3.useWriterConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Song +import com.subtracks.data.model.Source +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LibraryHomeDaoTest { + private lateinit var db: SubtracksDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun recentlyPlayedAlbumsOrderByMostRecentFirstAndLimit() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Alpha", played = 100), + album(sourceId, "b", "Beta", played = 300), + album(sourceId, "c", "Gamma", played = 200), + album(sourceId, "d", "Never", played = null), + ), + ) + + assertEquals( + listOf("Beta", "Gamma"), + dao.recentlyPlayedAlbums(sourceId, limit = 2).first().map { it.name }, + ) + assertEquals( + listOf("Beta", "Gamma", "Alpha"), + dao.recentlyPlayedAlbums(sourceId, limit = 10).first().map { it.name }, + ) + } + + @Test + fun mostPlayedAlbumsIgnoreUnplayedAndOrderByCount() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Alpha", playCount = 3), + album(sourceId, "b", "Beta", playCount = 9), + album(sourceId, "c", "Gamma", playCount = 0), + ), + ) + + assertEquals( + listOf("Beta", "Alpha"), + dao.mostPlayedAlbums(sourceId, limit = 10).first().map { it.name }, + ) + } + + @Test + fun artistsUseAggregatedPlayData() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "r1", "Alpha", played = 50, playCount = 1), + artist(sourceId, "r2", "Beta", played = 200, playCount = 5), + artist(sourceId, "r3", "Gamma", played = null, playCount = 0), + ), + ) + + assertEquals( + listOf("Beta", "Alpha"), + dao.recentlyPlayedArtists(sourceId, limit = 10).first().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.mostPlayedArtists(sourceId, limit = 10).first().map { it.name }, + ) + } + + @Test + fun recentlyStarredSongsAreNewestFirstJoinAlbumCoverArtAndLimit() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1"))) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "First", starred = 100), + song(sourceId, "s2", "Second", starred = 300), + song(sourceId, "s3", "Third", starred = null), + ), + ) + + val starred = dao.recentlyStarredSongs(sourceId, limit = 1).first() + assertEquals(listOf("s2"), starred.map { it.song.id }) + assertEquals("cover-1", starred.single().coverArt) + } + + @Test + fun genresOrderByMostRecentPlayWithinTheGenre() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertSongs( + listOf( + song(sourceId, "s1", "One", genre = "Rock", played = 100), + song(sourceId, "s2", "Two", genre = "Jazz", played = 400), + song(sourceId, "s3", "Three", genre = "Rock", played = 300), + song(sourceId, "s4", "Four", genre = "Classical", played = null), + ), + ) + + assertEquals( + listOf("Jazz", "Rock", "Classical"), + dao.genresByRecentPlay(sourceId).first(), + ) + } + + @Test + fun genresComeFromTheGenreRelationsAndFallBackPerSong() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertSongs( + listOf( + song(sourceId, "s1", "One", genre = "Legacy", played = 100), + song(sourceId, "s2", "Two", genre = "Legacy", played = 300), + ), + ) + insertGenre(sourceId, "s2", 0, "Rock") + insertGenre(sourceId, "s2", 1, "Electronic") + + assertEquals( + listOf("Electronic", "Rock", "Legacy"), + dao.genresByRecentPlay(sourceId).first(), + ) + } + + @Test + fun songsByGenreMatchesTheGenreRelationsWithFallback() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1"))) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Fallback", genre = "Rock"), + song(sourceId, "s2", "Relation", genre = "Jazz"), + song(sourceId, "s3", "NotRock", genre = "Rock"), + ), + ) + insertGenre(sourceId, "s2", 0, "Rock") + insertGenre(sourceId, "s3", 0, "Jazz") + + val page = dao.songsByGenre(sourceId, "Rock").page() + assertEquals(listOf("s1", "s2"), page.map { it.song.id }) + assertEquals("cover-1", page.first().coverArt) + } + + private suspend fun insertGenre( + sourceId: Long, + songId: String, + position: Long, + genre: String, + ) { + db.useWriterConnection { connection -> + connection.usePrepared( + "INSERT INTO song_genres (sourceId, songId, position, genre) " + + "VALUES ($sourceId, '$songId', $position, '$genre')", + ) { it.step() } + } + } + + @Test + fun decadesAreDistinctAndOldestFirstExcludingMissingYears() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "A", year = 1975), + album(sourceId, "b", "B", year = 1979), + album(sourceId, "c", "C", year = 2001), + album(sourceId, "d", "D", year = null), + album(sourceId, "e", "E", year = 0), + ), + ) + + assertEquals(listOf(1970L, 2000L), dao.decades(sourceId).first()) + } + + @Test + fun rediscoverOnlyIncludesStalePlaysOldestFirst() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Recent", played = 900), + album(sourceId, "b", "Old", played = 100), + album(sourceId, "c", "Older", played = 50), + album(sourceId, "d", "Never", played = null), + ), + ) + + assertEquals( + listOf("Older", "Old"), + dao.rediscoverAlbums(sourceId, cutoff = 500, limit = 10).first().map { it.name }, + ) + } + + @Test + fun homeMorePagingSourcesMatchTheirRowsWithoutLimits() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Alpha", created = 100, playCount = 3, played = 100), + album(sourceId, "b", "Beta", created = 300, playCount = 9, played = 300), + album(sourceId, "c", "Gamma", created = 200, playCount = 0, played = null), + ), + ) + dao.upsertArtists( + listOf( + artist(sourceId, "r1", "Alpha", playCount = 3, played = 100), + artist(sourceId, "r2", "Beta", playCount = 9, played = 300), + artist(sourceId, "r3", "Gamma", playCount = 0, played = null), + ), + ) + + assertEquals( + listOf("Beta", "Alpha"), + dao.homeRecentlyPlayedAlbums(sourceId).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.homeMostPlayedAlbums(sourceId).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Gamma", "Alpha"), + dao.homeRecentlyAddedAlbums(sourceId).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.homeRecentlyPlayedArtists(sourceId).page().map { it.name }, + ) + assertEquals( + listOf("Beta", "Alpha"), + dao.homeMostPlayedArtists(sourceId).page().map { it.name }, + ) + } + + @Test + fun rediscoverPagingSourceOrdersOldestFirst() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "Old", played = 100), + album(sourceId, "b", "Older", played = 50), + album(sourceId, "c", "Recent", played = 900), + ), + ) + + assertEquals( + listOf("Older", "Old"), + dao.albumsByRediscover(sourceId, starredFilter = 0, search = "", cutoff = 500).page().map { it.name }, + ) + } + + @Test + fun starredSongsPagingJoinsAlbumCoverArt() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1"))) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "First", starred = 100), + song(sourceId, "s2", "Second", starred = 300), + song(sourceId, "s3", "Third", starred = null), + ), + ) + + val page = dao.starredSongs(sourceId).page() + assertEquals(listOf("s2", "s1"), page.map { it.song.id }) + assertEquals("cover-1", page.first().coverArt) + } + + @Test + fun songsByGenrePagingFiltersAndJoinsAlbumCoverArt() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1"))) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Rock One", genre = "Rock"), + song(sourceId, "s2", "Jazz One", genre = "Jazz"), + song(sourceId, "s3", "Rock Two", genre = "Rock"), + ), + ) + + val page = dao.songsByGenre(sourceId, "Rock").page() + assertEquals(listOf("s1", "s3"), page.map { it.song.id }) + assertEquals("cover-1", page.first().coverArt) + } + + @Test + fun albumsByDecadePagingFiltersTheYearRange() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertAlbums( + listOf( + album(sourceId, "a", "A", year = 1969), + album(sourceId, "b", "B", year = 1970), + album(sourceId, "c", "C", year = 1979), + album(sourceId, "d", "D", year = 1980), + ), + ) + + assertEquals( + listOf("C", "B"), + dao.albumsByDecade(sourceId, start = 1970, end = 1980).page().map { it.name }, + ) + } + + private suspend fun source(): Long = + db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0)) + + private suspend fun PagingSource.page(): List = + ( + load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false), + ) as PagingSource.LoadResult.Page + ).data + + private fun album( + sourceId: Long, + id: String, + name: String, + year: Long? = null, + coverArt: String? = null, + created: Long = 0, + playCount: Long = 0, + played: Long? = null, + ) = Album( + sourceId = sourceId, + id = id, + artistId = "ar-1", + name = name, + albumArtist = "Artist", + created = created, + coverArt = coverArt, + genre = null, + year = year, + starred = null, + songCount = 1, + playCount = playCount, + played = played, + ) + + private fun artist( + sourceId: Long, + id: String, + name: String, + playCount: Long = 0, + played: Long? = null, + ) = Artist( + sourceId = sourceId, + id = id, + name = name, + albumCount = 1, + starred = null, + coverArt = null, + playCount = playCount, + played = played, + ) + + private fun song( + sourceId: Long, + id: String, + title: String, + starred: Long? = null, + genre: String? = null, + playCount: Long = 0, + played: Long? = null, + ) = Song( + sourceId = sourceId, + id = id, + albumId = "al1", + artistId = "ar-1", + title = title, + album = "Album", + artist = "Artist", + duration = 200, + track = 1, + disc = 1, + starred = starred, + genre = genre, + created = 0, + playCount = playCount, + played = played, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt new file mode 100644 index 00000000..b3e4c34c --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt @@ -0,0 +1,109 @@ +package com.subtracks.ui.home + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.captureRoboImage +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Song +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Render smoke test: asserts the awaited node exists and writes a PNG for local review; it does not verify pixels. */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) +class HomeScreenshotTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun home() { + composeRule.setContent { + SubtracksTheme { + HomeScreen( + feed = feed(), + coverArt = { _, _ -> null }, + playingSongId = null, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("Recently played").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onRoot().captureRoboImage("src/test/screenshots/home.png") + } + + private fun feed() = + HomeFeed( + recentlyPlayedAlbums = albums("Recent"), + recentlyPlayedArtists = artists("Recent"), + mostPlayedAlbums = albums("Frequent"), + mostPlayedArtists = artists("Frequent"), + genres = listOf("Rock", "Jazz", "Classical", "Electronic", "Pop", "Folk", "Blues"), + decades = listOf(1960L, 1970L, 1980L, 1990L, 2000L, 2010L), + recentlyStarredSongs = songs(), + recentlyAddedAlbums = albums("Added"), + rediscoverAlbums = albums("Repeat"), + ) + + private fun albums(prefix: String) = + (1..6).map { index -> + Album( + sourceId = 1, + id = "$prefix-$index", + artistId = "ar-$index", + name = "$prefix Album $index", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = 2000, + starred = null, + songCount = 1, + ) + } + + private fun artists(prefix: String) = + (1..6).map { index -> + Artist( + sourceId = 1, + id = "$prefix-ar-$index", + name = "$prefix Artist $index", + albumCount = 2, + starred = null, + coverArt = null, + ) + } + + private fun songs() = + (1..5).map { index -> + AlbumSongItem( + song = + Song( + sourceId = 1, + id = "song-$index", + albumId = "Recent-1", + artistId = "ar-1", + title = "Starred Song $index", + album = "Album", + artist = "Artist", + duration = 185, + track = index.toLong(), + disc = 1, + starred = 1000L - index, + genre = null, + ), + coverArt = null, + ) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt new file mode 100644 index 00000000..d65141ab --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt @@ -0,0 +1,111 @@ +package com.subtracks.ui.home + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.Album +import com.subtracks.data.model.AlbumSongItem +import com.subtracks.data.model.Artist +import com.subtracks.data.model.Song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeViewModelTest { + @Test + fun playRowsAreKeptWhenTheServerHasPlayData() { + val feed = + buildHomeFeed( + recentAlbums = listOf(album("a")), + recentArtists = listOf(artist("r")), + frequentAlbums = listOf(album("b")), + frequentArtists = listOf(artist("s")), + genres = listOf("Rock"), + decades = listOf(1970L), + starredSongs = listOf(starred("song")), + addedAlbums = listOf(album("c")), + rediscover = listOf(album("d")), + hasPlayData = true, + ) + + assertEquals(listOf("a"), feed.recentlyPlayedAlbums.map { it.id }) + assertEquals(listOf("r"), feed.recentlyPlayedArtists.map { it.id }) + assertEquals(listOf("b"), feed.mostPlayedAlbums.map { it.id }) + assertEquals(listOf("s"), feed.mostPlayedArtists.map { it.id }) + assertEquals(listOf("Rock"), feed.genres) + assertEquals(listOf(1970L), feed.decades) + assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id }) + assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id }) + assertEquals(listOf("d"), feed.rediscoverAlbums.map { it.id }) + } + + @Test + fun aServerWithNoPlayDataHidesThePlayRowsButKeepsTheRest() { + val feed = + buildHomeFeed( + recentAlbums = listOf(album("a")), + recentArtists = listOf(artist("r")), + frequentAlbums = listOf(album("b")), + frequentArtists = listOf(artist("s")), + genres = listOf("Rock"), + decades = listOf(1970L), + starredSongs = listOf(starred("song")), + addedAlbums = listOf(album("c")), + rediscover = listOf(album("d")), + hasPlayData = false, + ) + + assertTrue(feed.recentlyPlayedAlbums.isEmpty()) + assertTrue(feed.recentlyPlayedArtists.isEmpty()) + assertTrue(feed.mostPlayedAlbums.isEmpty()) + assertTrue(feed.mostPlayedArtists.isEmpty()) + assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id }) + assertEquals(listOf("Rock"), feed.genres) + assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id }) + } + + private fun album(id: String) = + Album( + sourceId = 1, + id = id, + artistId = "ar", + name = id, + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + ) + + private fun artist(id: String) = + Artist( + sourceId = 1, + id = id, + name = id, + albumCount = 1, + starred = null, + coverArt = null, + ) + + private fun starred(id: String) = + AlbumSongItem( + song = + Song( + sourceId = 1, + id = id, + albumId = "al", + artistId = "ar", + title = id, + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = 1, + genre = null, + ), + coverArt = null, + ) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/library/LibraryTabTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/LibraryTabTest.kt index 5775dcc5..c92888f8 100644 --- a/app/src/test/kotlin/com/subtracks/ui/library/LibraryTabTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/library/LibraryTabTest.kt @@ -8,9 +8,9 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LibraryTabTest { @Test - fun theStoredNameMapsToItsTabAndAnythingElseToAlbums() { + fun theStoredNameMapsToItsTabAndAnythingElseToHome() { assertEquals(LibraryTab.Playlists, libraryTabFor("Playlists")) - assertEquals(LibraryTab.Albums, libraryTabFor(null)) - assertEquals(LibraryTab.Albums, libraryTabFor("Books")) + assertEquals(LibraryTab.Home, libraryTabFor(null)) + assertEquals(LibraryTab.Home, libraryTabFor("Books")) } }