diff --git a/app/schemas/com.metrolist.music.db.InternalDatabase/38.json b/app/schemas/com.metrolist.music.db.InternalDatabase/38.json new file mode 100644 index 0000000000..2054816e27 --- /dev/null +++ b/app/schemas/com.metrolist.music.db.InternalDatabase/38.json @@ -0,0 +1,1368 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "bcf76ed36a3cb785f239e5cfbd28440e", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, `isCached` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT" + }, + { + "fieldPath": "explicit", + "columnName": "explicit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER" + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER" + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "libraryAddToken", + "columnName": "libraryAddToken", + "affinity": "TEXT" + }, + { + "fieldPath": "libraryRemoveToken", + "columnName": "libraryRemoveToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lyricsOffset", + "columnName": "lyricsOffset", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "romanizeLyrics", + "columnName": "romanizeLyrics", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isUploaded", + "columnName": "isUploaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isVideo", + "columnName": "isVideo", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isEpisode", + "columnName": "isEpisode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "playbackPosition", + "columnName": "playbackPosition", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "uploadEntityId", + "columnName": "uploadEntityId", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "isCached", + "columnName": "isCached", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isPodcastChannel` INTEGER NOT NULL DEFAULT false, `cachedPageJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isPodcastChannel", + "columnName": "isPodcastChannel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "cachedPageJson", + "columnName": "cachedPageJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "explicit", + "columnName": "explicit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER" + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isUploaded", + "columnName": "isUploaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "remoteSongCount", + "columnName": "remoteSongCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "playEndpointParams", + "columnName": "playEndpointParams", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "shuffleEndpointParams", + "columnName": "shuffleEndpointParams", + "affinity": "TEXT" + }, + { + "fieldPath": "radioEndpointParams", + "columnName": "radioEndpointParams", + "affinity": "TEXT" + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isAutoSync", + "columnName": "isAutoSync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "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 + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `perceptualLoudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL" + }, + { + "fieldPath": "perceptualLoudnessDb", + "columnName": "perceptualLoudnessDb", + "affinity": "REAL" + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Unknown'" + }, + { + "fieldPath": "translatedLyrics", + "columnName": "translatedLyrics", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translationLanguage", + "columnName": "translationLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translationMode", + "columnName": "translationMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "set_video_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + } + }, + { + "tableName": "playCount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))", + "fields": [ + { + "fieldPath": "song", + "columnName": "song", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song", + "year", + "month" + ] + } + }, + { + "tableName": "recognition_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "trackId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtUrl", + "columnName": "coverArtUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtHqUrl", + "columnName": "coverArtHqUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "releaseDate", + "columnName": "releaseDate", + "affinity": "TEXT" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "shazamUrl", + "columnName": "shazamUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "appleMusicUrl", + "columnName": "appleMusicUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "spotifyUrl", + "columnName": "spotifyUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isrc", + "columnName": "isrc", + "affinity": "TEXT" + }, + { + "fieldPath": "youtubeVideoId", + "columnName": "youtubeVideoId", + "affinity": "TEXT" + }, + { + "fieldPath": "recognizedAt", + "columnName": "recognizedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recognition_history_trackId", + "unique": false, + "columnNames": [ + "trackId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" + } + ] + }, + { + "tableName": "speed_dial_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `secondaryId` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `subtitleIds` TEXT, `thumbnailUrl` TEXT, `type` TEXT NOT NULL, `explicit` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `albumId` TEXT, `albumName` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondaryId", + "columnName": "secondaryId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleIds", + "columnName": "subtitleIds", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "explicit", + "columnName": "explicit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "podcast", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `thumbnailUrl` TEXT, `channelId` TEXT, `bookmarkedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "libraryAddToken", + "columnName": "libraryAddToken", + "affinity": "TEXT" + }, + { + "fieldPath": "libraryRemoveToken", + "columnName": "libraryRemoveToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "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, 'bcf76ed36a3cb785f239e5cfbd28440e')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt b/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt index d73d34e227..6d01430376 100644 --- a/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt +++ b/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt @@ -118,7 +118,7 @@ class MusicDatabase( SortedSongAlbumMap::class, PlaylistSongMapPreview::class, ], - version = 37, + version = 38, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), @@ -156,6 +156,7 @@ class MusicDatabase( AutoMigration(from = 34, to = 35), AutoMigration(from = 35, to = 36, spec = Migration35To36::class), AutoMigration(from = 36, to = 37), + AutoMigration(from = 37, to = 38), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistEntity.kt b/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistEntity.kt index dd86fa38a8..6e639f3aed 100644 --- a/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistEntity.kt +++ b/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistEntity.kt @@ -28,7 +28,9 @@ data class ArtistEntity( @ColumnInfo(name = "isLocal", defaultValue = false.toString()) val isLocal: Boolean = false, @ColumnInfo(name = "isPodcastChannel", defaultValue = false.toString()) - val isPodcastChannel: Boolean = false + val isPodcastChannel: Boolean = false, + @ColumnInfo(name = "cachedPageJson") + val cachedPageJson: String? = null ) { val isYouTubeArtist: Boolean get() = id.startsWith("UC") || id.startsWith("FEmusic_library_privately_owned_artist") diff --git a/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistPageCache.kt b/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistPageCache.kt new file mode 100644 index 0000000000..5dd7008e61 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/db/entities/ArtistPageCache.kt @@ -0,0 +1,278 @@ +package com.metrolist.music.db.entities + +import com.metrolist.innertube.models.ArtistItem +import com.metrolist.innertube.models.AlbumItem +import com.metrolist.innertube.models.EpisodeItem +import com.metrolist.innertube.models.PlaylistItem +import com.metrolist.innertube.models.PodcastItem +import com.metrolist.innertube.models.SongItem +import com.metrolist.innertube.models.YTItem +import com.metrolist.innertube.pages.ArtistSection +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true +} + +@Serializable +data class CachedArtistPage( + val artist: CachedArtistItem? = null, + val sections: List, + val description: String? = null, + val subscriberCountText: String? = null, + val monthlyListenerCount: String? = null, + val isSubscribed: Boolean = false, +) + +@Serializable +data class CachedArtistItem( + val id: String, + val title: String, + val thumbnail: String? = null, + val channelId: String? = null, + val isProfile: Boolean = false, +) + +@Serializable +data class CachedSection( + val title: String, + val items: List, + val moreBrowseId: String? = null, + val moreParams: String? = null, +) + +@Serializable +data class CachedItem( + val id: String, + val title: String, + val thumbnail: String, + val type: String, + val explicit: Boolean = false, + val artists: List = emptyList(), + val album: CachedAlbum? = null, + val duration: Int? = null, + val year: Int? = null, + val songCountText: String? = null, + val author: CachedArtist? = null, + val channelId: String? = null, + val browseId: String? = null, + val playlistId: String? = null, + val authorAvatarUrl: String? = null, + val episodeCountText: String? = null, + val videoId: String? = null, +) + +@Serializable +data class CachedArtist(val name: String, val id: String? = null) + +@Serializable +data class CachedAlbum(val name: String, val id: String) + +fun serializeArtistPage( + sections: List, + description: String?, + subscriberCountText: String?, + monthlyListenerCount: String?, + isSubscribed: Boolean, + artist: com.metrolist.innertube.models.ArtistItem? = null, +): String { + val cached = CachedArtistPage( + artist = artist?.let { CachedArtistItem(it.id, it.title, it.thumbnail, it.channelId, it.isProfile) }, + sections = sections.map { section -> + CachedSection( + title = section.title, + items = section.items.map { item -> item.toCachedItem() }, + moreBrowseId = section.moreEndpoint?.browseId, + moreParams = section.moreEndpoint?.params, + ) + }, + description = description, + subscriberCountText = subscriberCountText, + monthlyListenerCount = monthlyListenerCount, + isSubscribed = isSubscribed, + ) + return json.encodeToString(cached) +} + +fun deserializeArtistPage(jsonString: String): CachedArtistPage { + return json.decodeFromString(jsonString) +} + +fun CachedArtistPage.toArtistPage(): com.metrolist.innertube.pages.ArtistPage { + return com.metrolist.innertube.pages.ArtistPage( + artist = artist?.let { cached -> + com.metrolist.innertube.models.ArtistItem( + id = cached.id, + title = cached.title, + thumbnail = cached.thumbnail, + channelId = cached.channelId, + playEndpoint = null, + shuffleEndpoint = null, + radioEndpoint = null, + isProfile = cached.isProfile, + ) + } ?: com.metrolist.innertube.models.ArtistItem( + id = "", + title = "", + thumbnail = null, + shuffleEndpoint = null, + radioEndpoint = null, + ), + sections = sections.map { section -> + ArtistSection( + title = section.title, + items = section.items.map { it.toYTItem() }, + moreEndpoint = section.moreBrowseId?.let { browseId -> + com.metrolist.innertube.models.BrowseEndpoint( + browseId = browseId, + params = section.moreParams, + ) + }, + ) + }, + description = description, + subscriberCountText = subscriberCountText, + monthlyListenerCount = monthlyListenerCount, + isSubscribed = isSubscribed, + ) +} + +private fun CachedItem.toYTItem(): YTItem { + return when (type) { + "song" -> SongItem( + id = id, + title = title, + thumbnail = thumbnail, + explicit = explicit, + artists = artists.map { com.metrolist.innertube.models.Artist(it.name, it.id) }, + album = album?.let { com.metrolist.innertube.models.Album(it.name, it.id) }, + duration = duration ?: 0, + setVideoId = videoId, + ) + "album" -> AlbumItem( + browseId = browseId ?: id, + playlistId = playlistId ?: id, + id = id, + title = title, + thumbnail = thumbnail, + explicit = explicit, + artists = artists.map { com.metrolist.innertube.models.Artist(it.name, it.id) }.ifEmpty { null }, + year = year, + ) + "playlist" -> PlaylistItem( + id = id, + title = title, + thumbnail = thumbnail.takeIf { it.isNotBlank() }, + author = author?.let { com.metrolist.innertube.models.Artist(it.name, it.id) }, + songCountText = songCountText, + playEndpoint = null, + shuffleEndpoint = null, + radioEndpoint = null, + authorAvatarUrl = authorAvatarUrl, + ) + "artist" -> ArtistItem( + id = id, + title = title, + thumbnail = thumbnail.takeIf { it.isNotBlank() }, + channelId = channelId, + playEndpoint = null, + shuffleEndpoint = null, + radioEndpoint = null, + ) + "podcast" -> PodcastItem( + id = id, + title = title, + thumbnail = thumbnail.takeIf { it.isNotBlank() }, + author = author?.let { com.metrolist.innertube.models.Artist(it.name, it.id) }, + episodeCountText = episodeCountText, + playEndpoint = null, + shuffleEndpoint = null, + channelId = channelId, + ) + "episode" -> EpisodeItem( + id = id, + title = title, + thumbnail = thumbnail, + explicit = explicit, + author = author?.let { com.metrolist.innertube.models.Artist(it.name, it.id) }, + podcast = album?.let { com.metrolist.innertube.models.Album(it.name, it.id) }, + duration = duration, + endpoint = null, + ) + else -> SongItem( + id = id, + title = title, + thumbnail = thumbnail, + explicit = explicit, + artists = artists.map { com.metrolist.innertube.models.Artist(it.name, it.id) }, + album = album?.let { com.metrolist.innertube.models.Album(it.name, it.id) }, + duration = duration ?: 0, + ) + } +} + +private fun YTItem.toCachedItem(): CachedItem { + return when (this) { + is SongItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail, + type = "song", + explicit = explicit, + artists = artists.map { CachedArtist(it.name, it.id) }, + album = album?.let { CachedAlbum(it.name, it.id) }, + duration = duration, + videoId = setVideoId, + ) + is AlbumItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail, + type = "album", + explicit = explicit, + artists = artists?.map { CachedArtist(it.name, it.id) } ?: emptyList(), + year = year, + browseId = browseId, + playlistId = playlistId, + ) + is PlaylistItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail ?: "", + type = "playlist", + author = author?.let { CachedArtist(it.name, it.id) }, + songCountText = songCountText, + authorAvatarUrl = authorAvatarUrl, + ) + is ArtistItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail ?: "", + type = "artist", + channelId = channelId, + ) + is PodcastItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail ?: "", + type = "podcast", + author = author?.let { CachedArtist(it.name, it.id) }, + episodeCountText = episodeCountText, + channelId = channelId, + ) + is EpisodeItem -> CachedItem( + id = id, + title = title, + thumbnail = thumbnail, + type = "episode", + explicit = explicit, + author = author?.let { CachedArtist(it.name, it.id) }, + album = podcast?.let { CachedAlbum(it.name, it.id) }, + duration = duration, + ) + } +} diff --git a/app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherManager.kt b/app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherManager.kt index 6fdb594300..51753e6458 100644 --- a/app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherManager.kt +++ b/app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherManager.kt @@ -21,6 +21,8 @@ import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.utils.dataStore +import com.metrolist.music.utils.getArtistSeparator +import com.metrolist.music.utils.joinToArtistString import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -1719,11 +1721,11 @@ class ListenTogetherManager // Use a default duration of 3 minutes if duration is 0 or negative val durationMs = if (metadata.duration > 0) metadata.duration.toLong() * 1000 else 180000L - val trackInfo = - TrackInfo( - id = metadata.id, - title = metadata.title, - artist = metadata.artists.joinToString(", ") { it.name }, + val trackInfo = + TrackInfo( + id = metadata.id, + title = metadata.title, + artist = metadata.artists.joinToArtistString(getArtistSeparator(context)) { it.name }, album = metadata.album?.title, duration = durationMs, thumbnail = metadata.thumbnailUrl, @@ -1820,10 +1822,10 @@ class ListenTogetherManager private fun androidx.media3.common.Timeline.Window.toTrackInfo(): TrackInfo { val metadata = mediaItem.metadata ?: return TrackInfo("unknown", "Unknown", "Unknown", "", 0, "") val durationMs = if (metadata.duration > 0) metadata.duration.toLong() * 1000 else 180000L - return TrackInfo( - id = metadata.id, - title = metadata.title, - artist = metadata.artists.joinToString(", ") { it.name }, + return TrackInfo( + id = metadata.id, + title = metadata.title, + artist = metadata.artists.joinToArtistString(getArtistSeparator(context)) { it.name }, album = metadata.album?.title, duration = durationMs, thumbnail = metadata.thumbnailUrl, diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MediaLibrarySessionCallback.kt b/app/src/main/kotlin/com/metrolist/music/playback/MediaLibrarySessionCallback.kt index 469f4e9563..e353a27f62 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MediaLibrarySessionCallback.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MediaLibrarySessionCallback.kt @@ -46,6 +46,8 @@ import com.metrolist.music.extensions.toggleRepeatMode import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get +import com.metrolist.music.utils.getArtistSeparator +import com.metrolist.music.utils.joinToArtistString import com.metrolist.music.utils.reportException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -422,8 +424,8 @@ constructor( .setMediaMetadata( MediaMetadata.Builder() .setTitle(songItem.title) - .setSubtitle(songItem.artists.joinToString(", ") { it.name }) - .setArtist(songItem.artists.joinToString(", ") { it.name }) + .setSubtitle(songItem.artists.joinToArtistString(getArtistSeparator(context)) { it.name }) + .setArtist(songItem.artists.joinToArtistString(getArtistSeparator(context)) { it.name }) .setArtworkUri(songItem.thumbnail.toUri()) .setIsPlayable(true) .setIsBrowsable(false) @@ -551,8 +553,8 @@ constructor( .setMediaMetadata( MediaMetadata.Builder() .setTitle(songItem.title) - .setSubtitle(songItem.artists.joinToString(", ") { it.name }) - .setArtist(songItem.artists.joinToString(", ") { it.name }) + .setSubtitle(songItem.artists.joinToArtistString(getArtistSeparator(context)) { it.name }) + .setArtist(songItem.artists.joinToArtistString(getArtistSeparator(context)) { it.name }) .setArtworkUri(songItem.thumbnail.toUri()) .setIsPlayable(true) .setIsBrowsable(true) @@ -826,12 +828,12 @@ constructor( .Builder() .setMediaId("$path/$id") .setMediaMetadata( - MediaMetadata - .Builder() - .setTitle(song.title) - .setSubtitle(artists.joinToString { it.name }) - .setArtist(artists.joinToString { it.name }) - .setArtworkData(artworkBytes, MediaMetadata.PICTURE_TYPE_ILLUSTRATION) + MediaMetadata + .Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToArtistString(getArtistSeparator(context)) { it.name }) + .setArtist(artists.joinToArtistString(getArtistSeparator(context)) { it.name }) + .setArtworkData(artworkBytes, MediaMetadata.PICTURE_TYPE_ILLUSTRATION) .setIsPlayable(isPlayable) .setIsBrowsable(isBrowsable) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt index a14394c0c4..aff69c65b2 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt @@ -190,6 +190,8 @@ import com.metrolist.music.utils.DiscordRPC import com.metrolist.music.utils.NetworkConnectivityObserver import com.metrolist.music.utils.ScrobbleManager import com.metrolist.music.utils.SyncUtils +import com.metrolist.music.utils.getArtistSeparator +import com.metrolist.music.utils.joinToArtistString import com.metrolist.music.utils.YTPlayerUtils import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get @@ -4160,7 +4162,7 @@ class MusicService : val songData = currentSong.value val song = songData?.song val songTitle = song?.title ?: getString(R.string.no_song_playing) - val artistName = songData?.artists?.joinToString(", ") { it.name } ?: getString(R.string.tap_to_open) + val artistName = songData?.artists?.joinToArtistString(getArtistSeparator(this@MusicService)) { it.name } ?: getString(R.string.tap_to_open) val isLiked = songData?.song?.liked == true widgetManager.updateWidgets( diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/Items.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/Items.kt index 39752061b6..cf619702d3 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/component/Items.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/Items.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -68,6 +69,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -96,6 +98,7 @@ import com.metrolist.innertube.models.YTItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerConnection +import androidx.navigation.NavController import com.metrolist.music.R import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.GridItemSize @@ -108,6 +111,7 @@ import com.metrolist.music.constants.SwipeToSongKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist +import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem @@ -115,6 +119,7 @@ import com.metrolist.music.models.MediaMetadata import com.metrolist.music.playback.queues.LocalAlbumRadio import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.joinByBullet +import com.metrolist.music.utils.joinToArtistString import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference @@ -126,9 +131,98 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt +import kotlin.jvm.JvmName const val ActiveBoxAlpha = 0.6f +@JvmName("ClickableArtistTextEntities") +@Composable +fun ClickableArtistText( + artists: List, + navController: NavController, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, + maxLines: Int = 1, + overflow: TextOverflow = TextOverflow.Ellipsis, +) { + val andString = stringResource(R.string.and) + val annotatedString = remember(artists, andString) { + buildAnnotatedString { + artists.forEachIndexed { index, artist -> + artist.id.let { id -> + pushStringAnnotation("artist_$id", id) + append(artist.name) + pop() + } + if (index != artists.lastIndex) { + if (index == artists.lastIndex - 1) { + append(" $andString ") + } else { + append(", ") + } + } + } + } + } + ClickableText( + text = annotatedString, + style = style, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + onClick = { offset -> + annotatedString + .getStringAnnotations(offset, offset) + .firstOrNull() + ?.let { navController.navigate("artist/${it.item}") } + }, + ) +} + +@JvmName("ClickableArtistTextMedia") +@Composable +fun ClickableArtistText( + artists: List, + navController: NavController, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, + maxLines: Int = 1, + overflow: TextOverflow = TextOverflow.Ellipsis, +) { + val andString = stringResource(R.string.and) + val annotatedString = remember(artists, andString) { + buildAnnotatedString { + artists.forEachIndexed { index, artist -> + artist.id?.let { id -> + pushStringAnnotation("artist_$id", id) + append(artist.name) + pop() + } ?: append(artist.name) + if (index != artists.lastIndex) { + if (index == artists.lastIndex - 1) { + append(" $andString ") + } else { + append(", ") + } + } + } + } + } + ClickableText( + text = annotatedString, + style = style, + maxLines = maxLines, + overflow = overflow, + modifier = modifier, + onClick = { offset -> + annotatedString + .getStringAnnotations(offset, offset) + .firstOrNull() + ?.let { navController.navigate("artist/${it.item}") } + }, + ) +} + @Composable fun currentGridThumbnailHeight(): Dp { val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) @@ -377,6 +471,7 @@ fun SongListItem( showInLibraryIcon: Boolean = false, showDownloadIcon: Boolean = true, subtitleOverride: String? = null, + navController: NavController? = null, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { Icon.Favorite() @@ -402,30 +497,56 @@ fun SongListItem( val swipeEnabled by rememberPreference(SwipeToSongKey, defaultValue = false) val content: @Composable () -> Unit = { - ListItem( - title = song.song.title, - subtitle = subtitleOverride ?: joinByBullet( - song.orderedArtists.joinToString { it.name }, - makeTimeString(song.song.duration * 1000L) - ), - badges = badges, - thumbnailContent = { - ItemThumbnail( - thumbnailUrl = song.song.thumbnailUrl?.resize(200, 200), - albumIndex = albumIndex, - isSelected = isSelected, - isActive = isActive, - isPlaying = isPlaying, - shape = RoundedCornerShape(ThumbnailCornerRadius), - modifier = Modifier.size(ListThumbnailSize) - ) - }, - trailingContent = trailingContent, - modifier = modifier, - isSelected = isSelected, - isActive = isActive - ) - } + ListItem( + title = song.song.title, + subtitle = { + badges() + if (navController != null && subtitleOverride == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableArtistText( + artists = song.orderedArtists, + navController = navController, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.secondary), + modifier = Modifier.weight(1f) + ) + Text( + text = " | ${makeTimeString(song.song.duration * 1000L)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + Text( + text = subtitleOverride ?: joinByBullet( + song.orderedArtists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + thumbnailContent = { + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl?.resize(200, 200), + albumIndex = albumIndex, + isSelected = isSelected, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.size(ListThumbnailSize) + ) + }, + trailingContent = trailingContent, + modifier = modifier, + isSelected = isSelected, + isActive = isActive + ) + } if (isSwipeable && swipeEnabled) { SwipeToSongBox( @@ -446,6 +567,7 @@ fun SongGridItem( showLikedIcon: Boolean = true, showInLibraryIcon: Boolean = false, showDownloadIcon: Boolean = true, + navController: NavController? = null, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { Icon.Favorite() @@ -473,16 +595,39 @@ fun SongGridItem( ) }, subtitle = { - Text( - text = joinByBullet( - song.orderedArtists.joinToString { it.name }, - makeTimeString(song.song.duration * 1000L) - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + if (navController != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + ClickableArtistText( + artists = song.orderedArtists, + navController = navController, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.secondary), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = " | ${makeTimeString(song.song.duration * 1000L)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + Text( + text = joinByBullet( + song.orderedArtists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } }, badges = badges, thumbnailContent = { @@ -581,6 +726,7 @@ fun AlbumListItem( album: Album, modifier: Modifier = Modifier, showLikedIcon: Boolean = true, + navController: NavController? = null, badges: @Composable RowScope.() -> Unit = { val downloadUtil = LocalDownloadUtil.current val database = LocalDatabase.current @@ -620,12 +766,38 @@ fun AlbumListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = album.album.title, - subtitle = joinByBullet( - album.artists.joinToString { it.name }, - pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), - album.album.year?.toString() - ), - badges = badges, + subtitle = { + badges() + if (navController != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableArtistText( + artists = album.artists, + navController = navController, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.secondary), + modifier = Modifier.weight(1f) + ) + Text( + text = " | ${pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount)}${album.album.year?.let { " | $it" } ?: ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + Text( + text = joinByBullet( + album.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, thumbnailContent = { ItemThumbnail( thumbnailUrl = album.album.thumbnailUrl, @@ -692,10 +864,10 @@ fun AlbumGridItem( modifier = Modifier.basicMarquee().fillMaxWidth() ) }, - subtitle = { - Text( - text = album.artists.joinToString { it.name }, - style = MaterialTheme.typography.bodyMedium, + subtitle = { + Text( + text = album.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis @@ -925,29 +1097,75 @@ fun MediaMetadataListItem( isSelected: Boolean = false, isActive: Boolean = false, isPlaying: Boolean = false, + navController: NavController? = null, trailingContent: @Composable RowScope.() -> Unit = {}, ) { ListItem( title = mediaMetadata.title, - subtitle = if (mediaMetadata.suggestedBy != null) { - buildAnnotatedString { - append(mediaMetadata.artists.joinToString { it.name }) - append(" • ") - append(makeTimeString(mediaMetadata.duration * 1000L)) - append(" • ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(mediaMetadata.suggestedBy) + subtitle = { + if (mediaMetadata.explicit) Icon.Explicit() + if (navController != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableArtistText( + artists = mediaMetadata.artists, + navController = navController, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.secondary), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = " | ${makeTimeString(mediaMetadata.duration * 1000L)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (mediaMetadata.suggestedBy != null) { + Text( + text = " | ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = mediaMetadata.suggestedBy, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - } - } else { - AnnotatedString( - joinByBullet( - mediaMetadata.artists.joinToString { it.name }, - makeTimeString(mediaMetadata.duration * 1000L) + } else if (mediaMetadata.suggestedBy != null) { + Text( + text = buildAnnotatedString { + append(mediaMetadata.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }) + append(" | ") + append(makeTimeString(mediaMetadata.duration * 1000L)) + append(" | ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(mediaMetadata.suggestedBy) + } + }, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.secondary), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - ) + } else { + Text( + text = joinByBullet( + mediaMetadata.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + makeTimeString(mediaMetadata.duration * 1000L) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } }, - badges = { if (mediaMetadata.explicit) Icon.Explicit()}, thumbnailContent = { ItemThumbnail( thumbnailUrl = mediaMetadata.thumbnailUrl, @@ -1006,8 +1224,8 @@ fun YouTubeListItem( ListItem( title = item.title, subtitle = when (item) { - is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) - is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is SongItem -> joinByBullet(item.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, item.year?.toString()) is ArtistItem -> null is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) is PodcastItem -> joinByBullet(item.author?.name, item.episodeCountText) @@ -1085,10 +1303,10 @@ fun YouTubeGridItem( modifier = Modifier.basicMarquee().fillMaxWidth() ) }, - subtitle = { - val subtitle = when (item) { - is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) - is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + subtitle = { + val subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, item.year?.toString()) is ArtistItem -> null is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) is PodcastItem -> joinByBullet(item.author?.name, item.episodeCountText) diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/Library.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/Library.kt index 442265b84b..dbbc16ff7e 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/component/Library.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/Library.kt @@ -99,6 +99,7 @@ fun LibraryAlbumListItem( isPlaying: Boolean = false ) = AlbumListItem( album = album, + navController = navController, isActive = isActive, isPlaying = isPlaying, trailingContent = { diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/SongDropdownSelect.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/SongDropdownSelect.kt index 7fe5f7dcb7..1fd9ad1e63 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/component/SongDropdownSelect.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/SongDropdownSelect.kt @@ -99,7 +99,7 @@ fun SongSelectDropdown( maxLines = 1, ) Spacer(modifier = Modifier.width(8.dp)) - val displayArtists = song.artists.joinToString(", ") { it.name }.ifBlank { song.artistName } + val displayArtists = song.artists.joinToString(" ${stringResource(R.string.and)} ") { it.name }.ifBlank { song.artistName } displayArtists?.let { Text( text = it, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt index 49d5c5bc81..e09855b98a 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt @@ -215,7 +215,7 @@ fun AlbumMenu( } items(notAddedList) { song -> - SongListItem(song = song) + SongListItem(song = song, navController = navController) } } } @@ -270,6 +270,7 @@ fun AlbumMenu( AlbumListItem( album = album, + navController = navController, showLikedIcon = false, badges = {}, trailingContent = { diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt index 8c7941d353..814556c038 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt @@ -185,6 +185,7 @@ fun QueueMenu( val isFavorite = if (isEpisode) librarySong?.song?.inLibrary != null else librarySong?.song?.liked == true MediaMetadataListItem( mediaMetadata = mediaMetadata, + navController = navController, trailingContent = { IconButton( onClick = { diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt index 5bfd90970a..0d7506a10d 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt @@ -274,7 +274,7 @@ fun SongMenu( } items(listOf(song)) { song -> - SongListItem(song = song) + SongListItem(song = song, navController = navController) } } } @@ -425,6 +425,7 @@ fun SongMenu( SongListItem( song = song, + navController = navController, badges = {}, trailingContent = { // For episodes, show saved state and toggle save for later diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt index 2f8424ac91..2189d3689a 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt @@ -190,7 +190,7 @@ fun YouTubeAlbumMenu( } items(notAddedList) { song -> - SongListItem(song = song) + SongListItem(song = song, navController = navController) } } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt b/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt index b594eb1b4b..154ba4b435 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt @@ -100,6 +100,7 @@ import com.metrolist.music.playback.CastConnectionHandler import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.utils.resize +import com.metrolist.music.utils.joinToArtistString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.launch @@ -670,10 +671,10 @@ private fun NewMiniPlayerSongInfo( verticalAlignment = Alignment.CenterVertically, ) { if (metadata.explicit) MIcon.Explicit() - if (metadata.artists.any { it.name.isNotBlank() }) { - Text( - text = metadata.artists.joinToString { it.name }, - color = onSurfaceColor.copy(alpha = 0.7f), + if (metadata.artists.any { it.name.isNotBlank() }) { + Text( + text = metadata.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + color = onSurfaceColor.copy(alpha = 0.7f), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Clip, @@ -1031,10 +1032,10 @@ private fun LegacyMiniMediaInfo( modifier = Modifier.basicMarquee(), ) - if (mediaMetadata.artists.any { it.name.isNotBlank() }) { - Text( - text = mediaMetadata.artists.joinToString { it.name }, - color = MaterialTheme.colorScheme.secondary, + if (mediaMetadata.artists.any { it.name.isNotBlank() }) { + Text( + text = mediaMetadata.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, + color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt b/app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt index 5750e2d0fc..8fbf73bf88 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt @@ -836,6 +836,7 @@ fun Queue( ) { MediaMetadataListItem( mediaMetadata = window.mediaItem.metadata!!, + navController = navController, isSelected = false, isActive = isActive, isPlaying = isPlaying && isActive, @@ -969,6 +970,7 @@ fun Queue( ) { MediaMetadataListItem( mediaMetadata = item.metadata!!, + navController = navController, trailingContent = { if (!isListenTogetherGuest) { IconButton( diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/AlbumScreen.kt index d475c509b6..660222e996 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/AlbumScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/AlbumScreen.kt @@ -62,15 +62,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withLink -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachReversed import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.exoplayer.offline.Download @@ -86,6 +81,7 @@ import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.entities.Album import com.metrolist.music.playback.queues.LocalAlbumRadio +import com.metrolist.music.ui.component.ClickableArtistText import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle @@ -238,31 +234,15 @@ fun AlbumScreen( Spacer(modifier = Modifier.height(8.dp)) // Artist Names - Below the album name - Text( - buildAnnotatedString { - withStyle( - style = - MaterialTheme.typography.titleMedium - .copy( - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onBackground, - ).toSpanStyle(), - ) { - albumWithSongs.artists.fastForEachIndexed { index, artist -> - val link = - LinkAnnotation.Clickable(artist.id) { - navController.navigate("artist/${artist.id}") - } - withLink(link) { - append(artist.name) - } - if (index != albumWithSongs.artists.lastIndex) { - append(", ") - } - } - } - }, - textAlign = TextAlign.Center, + ClickableArtistText( + artists = albumWithSongs.artists, + navController = navController, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(12.dp)) @@ -294,7 +274,7 @@ fun AlbumScreen( ), ) if (totalDuration > 0) { - append(" • ") + append(" | ") append(makeTimeString(totalDuration * 1000L)) } }, @@ -428,6 +408,7 @@ fun AlbumScreen( SongListItem( song = song, + navController = navController, albumIndex = index + 1, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/HistoryScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/HistoryScreen.kt index d3defc2593..33fce65d54 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/HistoryScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/HistoryScreen.kt @@ -340,6 +340,7 @@ fun HistoryScreen( SongListItem( song = event.song, + navController = navController, isActive = event.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/HomeScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/HomeScreen.kt index 88f0dbda58..864aec28d6 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/HomeScreen.kt @@ -159,6 +159,7 @@ import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.SnapLayoutInfoProvider import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.joinByBullet +import com.metrolist.music.utils.joinToArtistString import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference @@ -368,7 +369,7 @@ fun CommunityPlaylistCard( overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) Text( - text = song.artists.joinToString(", ") { it.name }, + text = song.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), maxLines = 1, @@ -584,9 +585,9 @@ fun DailyDiscoverCard( Text( text = buildString { - append((dailyDiscover.recommendation as? SongItem)?.artists?.joinToString(", ") { it.name } ?: "") + append((dailyDiscover.recommendation as? SongItem)?.artists?.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name } ?: "") if (playCount > 0) { - append(" • $playCount $playsString") + append(" | $playCount $playsString") } }, style = MaterialTheme.typography.bodyMedium, @@ -611,7 +612,7 @@ fun DailyDiscoverCard( text = stringResource( messageRes, - "${dailyDiscover.seed.title} • ${dailyDiscover.seed.artists.joinToString(", ") { it.name }}", + "${dailyDiscover.seed.title} • ${dailyDiscover.seed.artists.joinToArtistString(" ${stringResource(R.string.and)} ") { it.name }}", ), style = MaterialTheme.typography.bodySmall, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, @@ -797,6 +798,7 @@ fun HomeScreen( is Song -> { SongGridItem( song = it, + navController = navController, modifier = Modifier .fillMaxWidth() @@ -1808,6 +1810,7 @@ fun HomeScreen( SongListItem( song = song!!, + navController = navController, showInLibraryIcon = true, isActive = song!!.id == mediaMetadata?.id, isPlaying = isPlaying, @@ -2133,6 +2136,7 @@ fun HomeScreen( SongListItem( song = song!!, + navController = navController, showInLibraryIcon = true, isActive = song!!.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistScreen.kt index eedde99aba..1617522a63 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistScreen.kt @@ -187,7 +187,7 @@ fun ArtistScreen( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { - if (artistPage == null && !showLocal) { + if (artistPage == null && !showLocal && libraryArtist == null) { item(key = "shimmer") { ShimmerHost( modifier = @@ -514,6 +514,15 @@ fun ArtistScreen( } } + // Show loading shimmer for sections when API hasn't returned yet + if (artistPage == null && !showLocal) { + item(key = "section_shimmer") { + ShimmerHost { + repeat(4) { ListItemPlaceHolder() } + } + } + } + if (showLocal) { if (librarySongs.isNotEmpty()) { item(key = "local_songs_title") { @@ -538,6 +547,7 @@ fun ArtistScreen( ) { index, song -> SongListItem( song = song, + navController = navController, showInLibraryIcon = true, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistSongsScreen.kt index 72c3ab7f3a..18fdae90b6 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistSongsScreen.kt @@ -131,6 +131,7 @@ fun ArtistSongsScreen( ) { index, song -> SongListItem( song = song, + navController = navController, showInLibraryIcon = true, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryMixScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryMixScreen.kt index a2d4c06a8d..f4eb813db2 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryMixScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryMixScreen.kt @@ -623,6 +623,7 @@ fun LibraryMixScreen( is Song -> { SongListItem( song = item, + navController = navController, showInLibraryIcon = true, isActive = item.id == mediaMetadata?.id, isPlaying = isPlaying, @@ -722,6 +723,7 @@ fun LibraryMixScreen( is Album -> { AlbumListItem( album = item, + navController = navController, isActive = item.id == mediaMetadata?.album?.id, isPlaying = isPlaying, trailingContent = { @@ -953,6 +955,7 @@ fun LibraryMixScreen( is Song -> { SongGridItem( song = item, + navController = navController, showInLibraryIcon = true, isActive = item.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPodcastsScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPodcastsScreen.kt index 4829e15595..399b2c2e6d 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPodcastsScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPodcastsScreen.kt @@ -402,6 +402,7 @@ fun LibraryPodcastsScreen( ) SongListItem( song = episode, + navController = navController, showInLibraryIcon = false, isActive = episode.id == mediaMetadata?.id, isPlaying = isPlaying, @@ -550,7 +551,7 @@ private fun AutoPlaylistCard( buildString { append(stringResource(R.string.auto_playlist)) if (!episodeCount.isNullOrBlank()) { - append(" • ") + append(" | ") append(episodeCount) } }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibrarySongsScreen.kt index 5269ce857b..1759724c7f 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibrarySongsScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibrarySongsScreen.kt @@ -464,6 +464,7 @@ fun LibrarySongsScreen( ) { index, song -> SongListItem( song = song, + navController = navController, showInLibraryIcon = true, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/AutoPlaylistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/AutoPlaylistScreen.kt index cbe0220447..c27224e497 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/AutoPlaylistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/AutoPlaylistScreen.kt @@ -600,6 +600,7 @@ fun AutoPlaylistScreen( SongListItem( song = song, + navController = navController, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, @@ -928,7 +929,7 @@ private fun AutoPlaylistHeader( buildString { append(pluralStringResource(R.plurals.n_song, songs.size, songs.size)) if (likeLength > 0) { - append(" • ") + append(" | ") append(makeTimeString(likeLength * 1000L)) } }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/CachePlaylistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/CachePlaylistScreen.kt index bb909e7ab4..b78cb07b7d 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/CachePlaylistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/CachePlaylistScreen.kt @@ -264,6 +264,7 @@ fun CachePlaylistScreen( SongListItem( song = song, + navController = navController, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/LocalPlaylistScreen.kt index 09477a121d..d1860396fb 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -624,6 +624,7 @@ fun LocalPlaylistScreen( val content: @Composable () -> Unit = { SongListItem( song = song.song, + navController = navController, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, @@ -1267,7 +1268,7 @@ fun LocalPlaylistHeader( val metadataString = buildString { append(nSongs) if (durationText != null) { - append(" • ") + append(" | ") append(durationText) } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 79556ace3f..cc1d81750b 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -586,7 +586,7 @@ private fun OnlinePlaylistHeader( val metadataText = buildString { append(nSongs) if (durationText != null) { - append(" • ") + append(" | ") append(durationText) } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/TopPlaylistScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/TopPlaylistScreen.kt index 2cb1e068f3..9de0af78a3 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/TopPlaylistScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/TopPlaylistScreen.kt @@ -317,6 +317,7 @@ fun TopPlaylistScreen( SongListItem( song = song, + navController = navController, albumIndex = index + 1, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, @@ -583,7 +584,7 @@ private fun TopPlaylistHeader( text = buildString { append(pluralStringResource(R.plurals.n_song, songs.size, songs.size)) if (likeLength > 0) { - append(" • ") + append(" | ") append(makeTimeString(likeLength * 1000L)) } }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/search/LocalSearchScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/search/LocalSearchScreen.kt index d35e0d13f9..556b5039cb 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/search/LocalSearchScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/search/LocalSearchScreen.kt @@ -187,6 +187,7 @@ fun LocalSearchScreen( is Song -> { SongListItem( song = item, + navController = navController, showInLibraryIcon = true, isActive = item.id == mediaMetadata?.id, isPlaying = isPlaying, @@ -253,6 +254,7 @@ fun LocalSearchScreen( is Album -> { AlbumListItem( album = item, + navController = navController, isActive = item.id == mediaMetadata?.album?.id, isPlaying = isPlaying, modifier = diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AlarmSettings.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AlarmSettings.kt index 0934792d7b..ea18626157 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AlarmSettings.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AlarmSettings.kt @@ -213,7 +213,7 @@ fun AlarmSettingsSection(showTitle: Boolean = true) { } val description = buildString { append(playlistTitle) - append(" • ") + append(" | ") append(if (alarm.randomSong) randomEnabledText else randomDisabledText) append("\n") append(stringResource(R.string.alarm_next_prefix, triggerText)) diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5SongsScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5SongsScreen.kt index 09008e6c7d..f2c76048f7 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5SongsScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5SongsScreen.kt @@ -5,6 +5,7 @@ package com.metrolist.music.ui.screens.wrapped.pages + import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -115,7 +116,7 @@ fun WrappedTop5SongsScreen(topSongs: List, isVisible: Boolean) { fontSize = 16.sp ) Text( - text = song.artists.joinToString(", ") { it.name }.ifBlank { song.artistName.orEmpty() }, + text = song.artists.joinToString(" ${stringResource(R.string.and)} ") { it.name }.ifBlank { song.artistName.orEmpty() }, color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp ) diff --git a/app/src/main/kotlin/com/metrolist/music/utils/StringUtils.kt b/app/src/main/kotlin/com/metrolist/music/utils/StringUtils.kt index bea7021a3f..ab9f4c7f5e 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/StringUtils.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/StringUtils.kt @@ -33,4 +33,4 @@ fun joinByBullet(vararg str: String?) = str .filterNot { it.isNullOrEmpty() - }.joinToString(separator = " • ") + }.joinToString(separator = " | ") diff --git a/app/src/main/kotlin/com/metrolist/music/utils/Utils.kt b/app/src/main/kotlin/com/metrolist/music/utils/Utils.kt index 75d51247bd..77f01e3b2b 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/Utils.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/Utils.kt @@ -7,8 +7,21 @@ package com.metrolist.music.utils import android.content.Context import android.content.res.Configuration +import com.metrolist.music.R import java.util.Locale +fun getArtistSeparator(context: Context): String = " ${context.getString(R.string.and)} " + +fun List.joinToArtistString( + conjunction: String, + transform: (T) -> String, +): String = when (size) { + 0 -> "" + 1 -> transform(this[0]) + 2 -> "${transform(this[0])}$conjunction${transform(this[1])}" + else -> dropLast(1).joinToString(", ") { transform(it) } + "$conjunction${transform(last())}" +} + fun reportException(throwable: Throwable) { throwable.printStackTrace() } diff --git a/app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistViewModel.kt b/app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistViewModel.kt index d30fa021b8..b3cf05434a 100644 --- a/app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistViewModel.kt @@ -22,6 +22,9 @@ import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.ArtistEntity +import com.metrolist.music.db.entities.deserializeArtistPage +import com.metrolist.music.db.entities.serializeArtistPage +import com.metrolist.music.db.entities.toArtistPage import com.metrolist.music.extensions.filterExplicit import com.metrolist.music.extensions.filterExplicitAlbums import com.metrolist.music.utils.SyncUtils @@ -37,6 +40,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -86,7 +90,12 @@ class ArtistViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { - // Load artist page and reload when hide explicit setting changes + // Load cached page first for instant display + viewModelScope.launch { + loadCachedPage() + } + + // Then listen for settings changes and fetch fresh data viewModelScope.launch { context.dataStore.data .map { @@ -103,6 +112,30 @@ class ArtistViewModel @Inject constructor( } } + private suspend fun loadCachedPage() { + try { + val cachedJson = database.artist(artistId).firstOrNull()?.artist?.cachedPageJson + if (cachedJson != null) { + val cachedDto = deserializeArtistPage(cachedJson) + val page = cachedDto.toArtistPage() + val hideExplicit = context.dataStore.get(HideExplicitKey, false) + val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) + val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) + + val filteredSections = page.sections + .map { section -> + section.copy(items = section.items.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs).filterYoutubeShorts(hideYoutubeShorts)) + } + .filter { section -> section.items.isNotEmpty() } + + artistPage = page.copy(sections = filteredSections) + _apiSubscribed.value = page.isSubscribed + } + } catch (e: Exception) { + reportException(e) + } + } + fun fetchArtistsFromYTM() { viewModelScope.launch { val hideExplicit = context.dataStore.get(HideExplicitKey, false) @@ -121,6 +154,43 @@ class ArtistViewModel @Inject constructor( .filter { section -> section.items.isNotEmpty() } artistPage = resolvedPage.copy(sections = filteredSections) + // Cache page data + persist artist metadata + viewModelScope.launch(Dispatchers.IO) { + try { + val cachedJson = serializeArtistPage( + sections = resolvedPage.sections, + description = resolvedPage.description, + subscriberCountText = resolvedPage.subscriberCountText, + monthlyListenerCount = resolvedPage.monthlyListenerCount, + isSubscribed = resolvedPage.isSubscribed, + artist = resolvedPage.artist, + ) + val existingArtist = database.artist(artistId).firstOrNull()?.artist + if (existingArtist != null) { + database.update( + existingArtist.copy( + name = resolvedPage.artist.title, + channelId = resolvedPage.artist.channelId ?: existingArtist.channelId, + thumbnailUrl = resolvedPage.artist.thumbnail ?: existingArtist.thumbnailUrl, + cachedPageJson = cachedJson, + ) + ) + } else { + val apiArtist = resolvedPage.artist + database.insert( + ArtistEntity( + id = artistId, + name = apiArtist.title, + channelId = apiArtist.channelId, + thumbnailUrl = apiArtist.thumbnail, + cachedPageJson = cachedJson, + ) + ) + } + } catch (e: Exception) { + reportException(e) + } + } // Store API subscription state _apiSubscribed.value = resolvedPage.isSubscribed }.onFailure { diff --git a/app/src/main/res/values-it/metrolist_strings.xml b/app/src/main/res/values-it/metrolist_strings.xml index be6556ad1f..bf366039f2 100644 --- a/app/src/main/res/values-it/metrolist_strings.xml +++ b/app/src/main/res/values-it/metrolist_strings.xml @@ -17,8 +17,9 @@ In cache Sincronizza playlist Sincronizzazione disabilitata - Nota: questo attiva la sincronizzazione con YouTube Music e NON è modificabile successivamente - Rimuovi dalla cache + Nota: questo attiva la sincronizzazione con YouTube Music e NON è modificabile successivamente + e + Rimuovi dalla cache Copia link Seleziona tutti Mi piace tutto diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/models/Runs.kt b/innertube/src/main/kotlin/com/metrolist/innertube/models/Runs.kt index 7f25486563..4f89e7c28a 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/models/Runs.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/models/Runs.kt @@ -41,9 +41,12 @@ fun List.splitArtistsByConjunction(): List { val parts = text.split(conjunctionPattern) parts.forEachIndexed { index, part -> if (part.isNotBlank()) { - result.add(Run(part.trim(), run.navigationEndpoint)) + result.add(Run(part.trim(), if (index == 0) run.navigationEndpoint else null)) } } + } else if (text.trim().equals("&", ignoreCase = true) || + words.any { text.trim().equals(it, ignoreCase = true) } + ) { } else { result.add(run) } diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistItemsPage.kt b/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistItemsPage.kt index c2f46147ad..a030f9e836 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistItemsPage.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistItemsPage.kt @@ -98,17 +98,12 @@ data class ArtistItemsPage( SongItem( id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, - artists = artistRuns.filter { - it.navigationEndpoint?.browseEndpoint?.browseId?.startsWith("UC") == true || - it.navigationEndpoint?.browseEndpoint != null - }.map { + artists = artistRuns.map { run -> Artist( - name = it.text.trim(), - id = it.navigationEndpoint?.browseEndpoint?.browseId + name = run.text.trim(), + id = run.navigationEndpoint?.browseEndpoint?.browseId ) - }.ifEmpty { - artistRuns.map { Artist(name = it.text.trim(), id = null) } - }, + }.ifEmpty { null } ?: return null, album = null, duration = null, musicVideoType = renderer.musicVideoType, diff --git a/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistPage.kt b/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistPage.kt index 364fb9e75b..5c38e8815d 100644 --- a/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistPage.kt +++ b/innertube/src/main/kotlin/com/metrolist/innertube/pages/ArtistPage.kt @@ -131,17 +131,12 @@ data class ArtistPage( SongItem( id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, - artists = artistRuns.filter { - it.navigationEndpoint?.browseEndpoint?.browseId?.startsWith("UC") == true || - it.navigationEndpoint?.browseEndpoint != null - }.map { + artists = artistRuns.map { run -> Artist( - name = it.text.trim(), - id = it.navigationEndpoint?.browseEndpoint?.browseId + name = run.text.trim(), + id = run.navigationEndpoint?.browseEndpoint?.browseId ) - }.ifEmpty { - artistRuns.map { Artist(name = it.text.trim(), id = null) } - }, + }.ifEmpty { null } ?: return null, album = null, duration = null, musicVideoType = renderer.musicVideoType,