From 4bc3671be14e345d784cc8f161ba694fc5d9857e Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Fri, 30 Sep 2022 19:27:34 +0200 Subject: [PATCH] Redesign ArtistScreen (#123, #172) --- .../21.json | 646 ++++++++++++++++++ .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 26 +- .../it/vfsfitvnm/vimusic/models/Artist.kt | 6 +- .../vfsfitvnm/vimusic/models/PartialArtist.kt | 9 + .../vfsfitvnm/vimusic/savers/ArtistSaver.kt | 13 +- .../vimusic/savers/YouTubeArtistPageSaver.kt | 36 + .../vimusic/savers/YouTubeArtistSaver.kt | 3 +- .../vimusic/ui/components/ShimmerHost.kt | 31 + .../ui/screens/artist/ArtistContent.kt | 155 +++++ .../ui/screens/artist/ArtistLocalSongsList.kt | 211 ++++++ .../ui/screens/artist/ArtistOverview.kt | 361 +++++++++- .../vimusic/ui/screens/artist/ArtistScreen.kt | 553 +++++++-------- .../vimusic/ui/screens/home/HomeArtistList.kt | 2 +- .../ui/screens/searchresult/SearchResult.kt | 5 +- .../vimusic/ui/views/YouTubeItems.kt | 70 ++ .../it/vfsfitvnm/vimusic/utils/LazyEffect.kt | 66 ++ .../it/vfsfitvnm/vimusic/utils/Preferences.kt | 1 + .../vimusic/utils/ProduceSaveableState.kt | 73 +- youtube-music/build.gradle.kts | 2 +- .../it/vfsfitvnm/youtubemusic/YouTube.kt | 206 +++++- .../youtubemusic/models/BrowseResponse.kt | 2 +- .../youtubemusic/models/Continuation.kt | 2 +- .../models/ContinuationResponse.kt | 2 +- .../youtubemusic/models/MusicShelfRenderer.kt | 2 - .../it/vfsfitvnm/youtubemusic/models/Tabs.kt | 5 +- 25 files changed, 2107 insertions(+), 381 deletions(-) create mode 100644 app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PartialArtist.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistPageSaver.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistContent.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyEffect.kt diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json new file mode 100644 index 0000000..a1e0a3d --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json @@ -0,0 +1,646 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "5afda34f61cc45ecd6102a7285ec92d2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `info` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_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": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, 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": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_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": "SearchQuery", + "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_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "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" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap 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, '5afda34f61cc45ecd6102a7285ec92d2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 201c28d..729bd2f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -10,6 +10,7 @@ import androidx.media3.common.MediaItem import androidx.room.AutoMigration import androidx.room.Dao import androidx.room.Delete +import androidx.room.DeleteColumn import androidx.room.DeleteTable import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -39,6 +40,7 @@ import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.Format +import it.vfsfitvnm.vimusic.models.PartialArtist import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.PlaylistWithSongs @@ -150,16 +152,16 @@ interface Database { @Query("SELECT * FROM Artist WHERE id = :id") fun artist(id: String): Flow - @Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY name DESC") + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC") fun artistsByNameDesc(): Flow> - @Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY name ASC") + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC") fun artistsByNameAsc(): Flow> - @Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY ROWID DESC") + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC") fun artistsByRowIdDesc(): Flow> - @Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY ROWID ASC") + @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC") fun artistsByRowIdAsc(): Flow> fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow> { @@ -378,7 +380,7 @@ interface Database { name = artistName, thumbnailUrl = null, info = null, - timestamp = null, + timestamp = null ).also(::insert) } } @@ -419,6 +421,9 @@ interface Database { @Upsert fun upsert(artist: Artist) + @Upsert(Artist::class) + fun upsert(artist: PartialArtist) + @Delete fun delete(searchQuery: SearchQuery) @@ -449,7 +454,7 @@ interface Database { views = [ SortedSongPlaylistMap::class ], - version = 20, + version = 21, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -468,6 +473,7 @@ interface Database { AutoMigration(from = 17, to = 18), AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), ], ) @TypeConverters(Converters::class) @@ -601,6 +607,14 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { it.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } + + @DeleteColumn.Entries( + DeleteColumn("Artist", "shuffleVideoId"), + DeleteColumn("Artist", "shufflePlaylistId"), + DeleteColumn("Artist", "radioVideoId"), + DeleteColumn("Artist", "radioPlaylistId"), + ) + class From20To21Migration : AutoMigrationSpec } @TypeConverters diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt index 9f826f2..adf3a14 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Artist.kt @@ -8,13 +8,9 @@ import androidx.room.PrimaryKey @Entity data class Artist( @PrimaryKey val id: String, - val name: String, + val name: String?, val thumbnailUrl: String?, val info: String?, - val shuffleVideoId: String? = null, - val shufflePlaylistId: String? = null, - val radioVideoId: String? = null, - val radioPlaylistId: String? = null, val timestamp: Long?, val bookmarkedAt: Long? = null, ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PartialArtist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PartialArtist.kt new file mode 100644 index 0000000..5e22657 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PartialArtist.kt @@ -0,0 +1,9 @@ +package it.vfsfitvnm.vimusic.models + +data class PartialArtist( + val id: String, + val name: String?, + val thumbnailUrl: String?, + val info: String?, + val timestamp: Long? = null, +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt index a609450..8247c8e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ArtistSaver.kt @@ -3,7 +3,6 @@ package it.vfsfitvnm.vimusic.savers import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.models.Playlist object ArtistSaver : Saver> { override fun SaverScope.save(value: Artist): List = listOf( @@ -11,10 +10,6 @@ object ArtistSaver : Saver> { value.name, value.thumbnailUrl, value.info, - value.shuffleVideoId, - value.shufflePlaylistId, - value.radioVideoId, - value.radioPlaylistId, value.timestamp, value.bookmarkedAt, ) @@ -24,11 +19,7 @@ object ArtistSaver : Saver> { name = value[1] as String, thumbnailUrl = value[2] as String?, info = value[3] as String?, - shuffleVideoId = value[4] as String?, - shufflePlaylistId = value[5] as String?, - radioVideoId = value[6] as String?, - radioPlaylistId = value[7] as String?, - timestamp = value[8] as Long?, - bookmarkedAt = value[9] as Long?, + timestamp = value[4] as Long?, + bookmarkedAt = value[5] as Long?, ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistPageSaver.kt new file mode 100644 index 0000000..ad9c8cf --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistPageSaver.kt @@ -0,0 +1,36 @@ +package it.vfsfitvnm.vimusic.savers + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.youtubemusic.YouTube + +object YouTubeArtistPageSaver : Saver> { + override fun SaverScope.save(value: YouTube.Artist): List = listOf( + value.name, + value.description, + value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }, + value.shuffleEndpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } }, + value.radioEndpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } }, + value.songs?.let { with(YouTubeSongListSaver) { save(it) } }, + value.songsEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } }, + value.albums?.let { with(YouTubeAlbumListSaver) { save(it) } }, + value.albumsEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } }, + value.singles?.let { with(YouTubeAlbumListSaver) { save(it) } }, + value.singlesEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } }, + ) + + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = YouTube.Artist( + name = value[0] as String?, + description = value[1] as String?, + thumbnail = (value[2] as List?)?.let(YouTubeThumbnailSaver::restore), + shuffleEndpoint = (value[3] as List?)?.let(YouTubeWatchEndpointSaver::restore), + radioEndpoint = (value[4] as List?)?.let(YouTubeWatchEndpointSaver::restore), + songs = (value[5] as List>?)?.let(YouTubeSongListSaver::restore), + songsEndpoint = (value[6] as List?)?.let(YouTubeBrowseEndpointSaver::restore), + albums = (value[7] as List>?)?.let(YouTubeAlbumListSaver::restore), + albumsEndpoint = (value[8] as List?)?.let(YouTubeBrowseEndpointSaver::restore), + singles = (value[9] as List>?)?.let(YouTubeAlbumListSaver::restore), + singlesEndpoint = (value[10] as List?)?.let(YouTubeBrowseEndpointSaver::restore), + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistSaver.kt index 7f602a7..2ebc020 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubeArtistSaver.kt @@ -2,13 +2,14 @@ package it.vfsfitvnm.vimusic.savers import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope +import it.vfsfitvnm.vimusic.savers.YouTubeThumbnailSaver.save import it.vfsfitvnm.youtubemusic.YouTube object YouTubeArtistSaver : Saver> { override fun SaverScope.save(value: YouTube.Item.Artist): List = listOf( value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } }, value.subscribersCountText, - with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } + value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } } ) override fun restore(value: List) = YouTube.Item.Artist( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt new file mode 100644 index 0000000..c4a8338 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ShimmerHost.kt @@ -0,0 +1,31 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + listOf(Color.Black, Color.Transparent) + ), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistContent.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistContent.kt new file mode 100644 index 0000000..4383bd2 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistContent.kt @@ -0,0 +1,155 @@ +package it.vfsfitvnm.vimusic.ui.screens.artist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.autoSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.models.Artist +import it.vfsfitvnm.vimusic.savers.ListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.produceSaveableRelaunchableOneShotState +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +inline fun ArtistContent( + artist: Artist?, + youtubeArtist: YouTube.Artist?, + isLoading: Boolean, + isError: Boolean, + stateSaver: ListSaver>, + crossinline itemsProvider: suspend (String?) -> Result?>>?, + crossinline bookmarkIconContent: @Composable () -> Unit, + crossinline shareIconContent: @Composable () -> Unit, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, + noinline itemShimmer: @Composable () -> Unit, +) { + val (_, typography) = LocalAppearance.current + + var items by rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(listOf()) + } + + var isLoadingItems by remember { + mutableStateOf(false) + } + + var isErrorItems by remember { + mutableStateOf(false) + } + + val (continuationState, fetch) = produceSaveableRelaunchableOneShotState( + initialValue = null, + stateSaver = autoSaver(), + youtubeArtist + ) { + if (youtubeArtist == null) return@produceSaveableRelaunchableOneShotState + + println("loading... $value") + + isLoadingItems = true + withContext(Dispatchers.IO) { + itemsProvider(value)?.onSuccess { (continuation, newItems) -> + value = continuation + newItems?.let { + items = items.plus(it).distinctBy(YouTube.Item::key) + } + isErrorItems = false + isLoadingItems = false + }?.onFailure { + println("error (2): $it") + isErrorItems = true + isLoadingItems = false + } + } + } + + val continuation by continuationState + + when { + artist != null -> { + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0, + ) { + Header(title = artist.name ?: "Unknown") { + bookmarkIconContent() + shareIconContent() + } + } + + items( + items = items, + key = YouTube.Item::key, + itemContent = itemContent + ) + + if (isError || isErrorItems) { + item(key = "error") { + BasicText( + text = "An error has occurred", + style = LocalAppearance.current.typography.s.secondary.center, + modifier = Modifier + .padding(all = 16.dp) + ) + } + } else { + item("loading") { + val hasMore = continuation != null + + if (hasMore || items.isEmpty()) { + ShimmerHost { + repeat(if (hasMore) 3 else 8) { + itemShimmer() + } + } + +// if (hasMore && items.isNotEmpty()) { +// println("loading again!") +// SideEffect(fetch) +// } + } + } + } + } + } + isError -> BasicText( + text = "An error has occurred", + style = LocalAppearance.current.typography.s.secondary.center, + modifier = Modifier + .padding(all = 16.dp) + ) + isLoading -> ShimmerHost { + HeaderPlaceholder() + + repeat(5) { + itemShimmer() + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt new file mode 100644 index 0000000..4fd4cdf --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt @@ -0,0 +1,211 @@ +package it.vfsfitvnm.vimusic.ui.screens.artist + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Artist +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.ui.styling.shimmer +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.thumbnail +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@Composable +fun ArtistLocalSongsList( + browseId: String, + artist: Artist?, + isLoading: Boolean, + isError: Boolean, + bookmarkIconContent: @Composable () -> Unit, + shareIconContent: @Composable () -> Unit, +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + val songs by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver + ) { + Database + .artistSongs(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val songThumbnailSizePx = Dimensions.thumbnails.song.px + + BoxWithConstraints { + val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth + val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px + + when { + artist != null -> { + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column { + Header(title = artist.name ?: "Unknown") { + SecondaryTextButton( + text = "Enqueue", + isEnabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) + } + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + bookmarkIconContent() + shareIconContent() + } + + AsyncImage( + model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(CircleShape) + .size(thumbnailSizeDp) + ) + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + song = song, + thumbnailSizePx = songThumbnailSizePx, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) + }, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + } + ) + } + } + + PrimaryButton( + iconId = R.drawable.shuffle, + isEnabled = songs.isNotEmpty(), + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + ) + } + isError -> Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + ) { + BasicText( + text = "An error has occurred.", + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.Center) + ) + } + isLoading -> ShimmerHost { + HeaderPlaceholder() + + Spacer( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(CircleShape) + .size(thumbnailSizeDp) + .background(colorPalette.shimmer) + ) + + repeat(3) { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .alpha(1f - index * 0.25f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = Dimensions.itemsVerticalPadding) + .height(Dimensions.thumbnails.song) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(Dimensions.thumbnails.song) + ) + + Column { + TextPlaceholder() + TextPlaceholder() + } + } + } + } + + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt index 95ba666..fdea1c9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistOverview.kt @@ -1,76 +1,373 @@ package it.vfsfitvnm.vimusic.ui.screens.artist -import android.content.Intent import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import coil.compose.AsyncImage import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.ui.views.AlternativeAlbumItem +import it.vfsfitvnm.vimusic.ui.views.AlternativeAlbumItemPlaceholder +import it.vfsfitvnm.vimusic.ui.views.SmallSongItem +import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint @ExperimentalAnimationApi -@ExperimentalFoundationApi @Composable fun ArtistOverview( - browseId: String, + artist: Artist?, + youtubeArtist: YouTube.Artist?, + isLoading: Boolean, + isError: Boolean, + onViewAllSongsClick: () -> Unit, + onViewAllAlbumsClick: () -> Unit, + onViewAllSinglesClick: () -> Unit, + onAlbumClick: (String) -> Unit, + bookmarkIconContent: @Composable () -> Unit, + shareIconContent: @Composable () -> Unit, ) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current - val context = LocalContext.current + + val songThumbnailSizeDp = Dimensions.thumbnails.song + val songThumbnailSizePx = songThumbnailSizeDp.px + val albumThumbnailSizeDp = 108.dp + val albumThumbnailSizePx = albumThumbnailSizeDp.px + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + + BoxWithConstraints { + val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth + val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(LocalPlayerAwarePaddingValues.current) + ) { + when { + artist != null -> { + Header(title = artist.name ?: "Unknown") { + youtubeArtist?.radioEndpoint?.let { radioEndpoint -> + SecondaryTextButton( + text = "Start radio", + onClick = { + binder?.stopRadio() + binder?.playRadio(radioEndpoint) + } + ) + } + + Spacer( + modifier = Modifier + .weight(1f) + ) + + bookmarkIconContent() + shareIconContent() + } + + AsyncImage( + model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(CircleShape) + .size(thumbnailSizeDp) + ) + + when { + youtubeArtist != null -> { + youtubeArtist.songs?.let { songs -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + ) { + BasicText( + text = "Songs", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtist.songsEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onViewAllSongsClick + ), + ) + } + } + + songs.forEach { song -> + SmallSongItem( + song = song, + thumbnailSizePx = songThumbnailSizePx, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + } + ) + } + } + + youtubeArtist.albums?.let { albums -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + ) { + BasicText( + text = "Albums", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtist.albumsEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onViewAllAlbumsClick + ), + ) + } + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + ) { + items( + items = albums, + key = YouTube.Item.Album::key + ) { album -> + AlternativeAlbumItem( + album = album, + thumbnailSizePx = albumThumbnailSizePx, + thumbnailSizeDp = albumThumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onAlbumClick(album.key) } + ) + ) + } + } + } + + youtubeArtist.singles?.let { singles -> + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + ) { + BasicText( + text = "Singles", + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + youtubeArtist.singlesEndpoint?.let { + BasicText( + text = "View all", + style = typography.xs.secondary, + modifier = sectionTextModifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onViewAllSinglesClick + ), + ) + } + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + ) { + items( + items = singles, + key = YouTube.Item.Album::key + ) { album -> + AlternativeAlbumItem( + album = album, + thumbnailSizePx = albumThumbnailSizePx, + thumbnailSizeDp = albumThumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onAlbumClick(album.key) } + ) + ) + } + } + } + } + isError -> ErrorText() + isLoading -> ShimmerHost { + TextPlaceholder(modifier = sectionTextModifier) + + repeat(5) { + SmallSongItemShimmer( + thumbnailSizeDp = songThumbnailSizeDp, + ) + } + + repeat(2) { + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + AlternativeAlbumItemPlaceholder(thumbnailSizeDp = albumThumbnailSizeDp) + } + } + } + } + } + } + isError -> ErrorText() + isLoading -> ShimmerHost { + HeaderPlaceholder() + + Spacer( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(CircleShape) + .size(thumbnailSizeDp) + .background(colorPalette.shimmer) + ) + + TextPlaceholder(modifier = sectionTextModifier) + + repeat(5) { + SmallSongItemShimmer( + thumbnailSizeDp = songThumbnailSizeDp, + ) + } + + repeat(2) { + TextPlaceholder(modifier = sectionTextModifier) + + Row { + repeat(2) { + AlternativeAlbumItemPlaceholder(thumbnailSizeDp = albumThumbnailSizeDp) + } + } + } + } + } + } + + youtubeArtist?.shuffleEndpoint?.let { shuffleEndpoint -> + PrimaryButton( + iconId = R.drawable.shuffle, + onClick = { + binder?.stopRadio() + binder?.playRadio(shuffleEndpoint) + } + ) + } + } +} + +@Composable +fun ColumnScope.ErrorText() { + BasicText( + text = "An error has occurred", + style = LocalAppearance.current.typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) +} + +@Composable +fun ShimmerHost(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + listOf(Color.Black, Color.Transparent) + ), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt index 7f70d1a..cb20a28 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt @@ -1,87 +1,182 @@ package it.vfsfitvnm.vimusic.ui.screens.artist +import android.content.Intent import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.Artist -import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.models.PartialArtist import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.savers.ArtistSaver +import it.vfsfitvnm.vimusic.savers.YouTubeAlbumListSaver +import it.vfsfitvnm.vimusic.savers.YouTubeArtistPageSaver +import it.vfsfitvnm.vimusic.savers.YouTubeSongListSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes +import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResult import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.ui.views.AlbumItem +import it.vfsfitvnm.vimusic.ui.views.AlbumItemShimmer +import it.vfsfitvnm.vimusic.ui.views.SmallSongItem +import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer +import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.produceSaveableLazyOneShotState +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.youtubemusic.YouTube -import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext -@OptIn(ExperimentalFoundationApi::class) @ExperimentalAnimationApi @Composable fun ArtistScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabIndexChanged) = rememberSaveable { - mutableStateOf(0) + val (tabIndex, onTabIndexChanged) = rememberPreference( + artistScreenTabIndexKey, + defaultValue = 0 + ) + + var isLoading by remember { + mutableStateOf(false) + } + + var isError by remember { + mutableStateOf(false) + } + + val youtubeArtist by produceSaveableLazyOneShotState( + initialValue = null, + stateSaver = nullableSaver(YouTubeArtistPageSaver) + ) { + println("${System.currentTimeMillis()}, computing lazyEffect (youtubeArtistResult = ${value?.name})!") + + isLoading = true + withContext(Dispatchers.IO) { + YouTube.artist(browseId)?.onSuccess { youtubeArtist -> + value = youtubeArtist + + query { + Database.upsert( + PartialArtist( + id = browseId, + name = youtubeArtist.name, + thumbnailUrl = youtubeArtist.thumbnail?.url, + info = youtubeArtist.description, + timestamp = System.currentTimeMillis() + ) + ) + } + isError = false + isLoading = false + }?.onFailure { + println("error (1): $it") + isError = true + isLoading = false + } + } + } + + val artist by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(ArtistSaver), + ) { + Database + .artist(browseId) + .flowOn(Dispatchers.IO) + .filter { + val hasToFetch = it?.timestamp == null + if (hasToFetch) { + youtubeArtist?.name + } + !hasToFetch + } + .collect { value = it } } RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { + val bookmarkIconContent: @Composable () -> Unit = { + Image( + painter = painterResource( + if (artist?.bookmarkedAt == null) { + R.drawable.bookmark_outline + } else { + R.drawable.bookmark + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.accent), + modifier = Modifier + .clickable { + val bookmarkedAt = if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + artist?.copy(bookmarkedAt = bookmarkedAt)?.let(Database::update) + } + } + .padding(all = 4.dp) + .size(18.dp) + ) + } + + val shareIconContent: @Composable () -> Unit = { + val context = LocalContext.current + + Image( + painter = painterResource(R.drawable.share_social), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.text), + modifier = Modifier + .clickable { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/channel/$browseId" + ) + } + + context.startActivity( + Intent.createChooser( + sendIntent, + null + ) + ) + } + .padding(all = 4.dp) + .size(18.dp) + ) + } + Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, @@ -92,273 +187,151 @@ fun ArtistScreen(browseId: String) { Item(1, "Songs", R.drawable.musical_notes) Item(2, "Albums", R.drawable.disc) Item(3, "Singles", R.drawable.disc) + Item(4, "Library", R.drawable.library) } - ) { currentTabIndex -> + ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - ArtistOverview(browseId = browseId) - } - } - } - } -} - -@ExperimentalAnimationApi -@Composable -fun ArtistScreen2(browseId: String) { - val lazyListState = rememberLazyListState() - - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val binder = LocalPlayerServiceBinder.current - - val (colorPalette, typography) = LocalAppearance.current - - val artistResult by remember(browseId) { - Database.artist(browseId).map { artist -> - artist - ?.takeIf { artist.timestamp != null } - ?.let(Result.Companion::success) - ?: fetchArtist(browseId) - }.distinctUntilChanged() - }.collectAsState(initial = null, context = Dispatchers.IO) - - val songThumbnailSizePx = Dimensions.thumbnails.song.px - - val songs by remember(browseId) { - Database.artistSongs(browseId) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - - } - - item { - artistResult?.getOrNull()?.let { artist -> - AsyncImage( - model = artist.thumbnailUrl?.thumbnail(Dimensions.thumbnails.artist.px), - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .size(Dimensions.thumbnails.artist) + when (currentTabIndex) { + 0 -> ArtistOverview( + artist = artist, + youtubeArtist = youtubeArtist, + isLoading = isLoading, + isError = isError, + bookmarkIconContent = bookmarkIconContent, + shareIconContent = shareIconContent, + onAlbumClick = { albumRoute(it) }, + onViewAllSongsClick = { onTabIndexChanged(1) }, + onViewAllAlbumsClick = { onTabIndexChanged(2) }, + onViewAllSinglesClick = { onTabIndexChanged(3) }, ) + 1 -> { + val binder = LocalPlayerServiceBinder.current + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px - BasicText( - text = artist.name, - style = typography.l.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.playRadio( - NavigationEndpoint.Endpoint.Watch( - videoId = artist.shuffleVideoId, - playlistId = artist.shufflePlaylistId - ) - ) - - query { - runBlocking { - fetchArtist(browseId) + ArtistContent( + artist = artist, + youtubeArtist = youtubeArtist, + isLoading = isLoading, + isError = isError, + stateSaver = YouTubeSongListSaver, + bookmarkIconContent = bookmarkIconContent, + shareIconContent = shareIconContent, + itemsProvider = { continuation -> + youtubeArtist + ?.songsEndpoint + ?.browseId + ?.let { browseId -> + YouTube.items(browseId, continuation, YouTube.Item.Song::from)?.map { result -> + result?.continuation to result?.items } } - } - .padding(all = 8.dp) - .size(20.dp) + }, + itemContent = { song -> + SmallSongItem( + song = song, + thumbnailSizePx = thumbnailSizePx, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } + ) + }, + itemShimmer = { + SmallSongItemShimmer(thumbnailSizeDp = thumbnailSizeDp) + } ) + } + 2 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px - Image( - painter = painterResource(R.drawable.radio), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.playRadio( - NavigationEndpoint.Endpoint.Watch( - videoId = artist.radioVideoId - ?: artist.shuffleVideoId, - playlistId = artist.radioPlaylistId - ) - ) - - query { - runBlocking { - fetchArtist(browseId) + ArtistContent( + artist = artist, + youtubeArtist = youtubeArtist, + isLoading = isLoading, + isError = isError, + stateSaver = YouTubeAlbumListSaver, + bookmarkIconContent = bookmarkIconContent, + shareIconContent = shareIconContent, + itemsProvider = { + youtubeArtist + ?.albumsEndpoint + ?.let { endpoint -> + YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result -> + result?.continuation to result?.items } } - } - .padding(all = 8.dp) - .size(20.dp) - ) - } - } ?: artistResult?.exceptionOrNull()?.let { throwable -> -// LoadingOrError( -// errorMessage = throwable.javaClass.canonicalName, -// onRetry = { -// query { -// runBlocking { -// Database.artist(browseId).first()?.let(Database::update) -// } -// } -// } -// ) - } - } - - item("songs") { - if (songs.isEmpty()) return@item - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .background(colorPalette.background0) - .zIndex(1f) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Local tracks", - style = typography.m.semiBold, - modifier = Modifier - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs - .shuffled() - .map(DetailedSong::asMediaItem) + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { albumRoute(album.info?.endpoint?.browseId) } + ) ) + }, + itemShimmer = { + AlbumItemShimmer(thumbnailSizeDp = thumbnailSizeDp) } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSizePx = songThumbnailSizePx, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(DetailedSong::asMediaItem), - index ) - }, - menuContent = { - InHistoryMediaItemMenu(song = song) } - ) - } + 3 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px - artistResult?.getOrNull()?.info?.let { description -> - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Information", - style = typography.m.semiBold, - modifier = Modifier - .padding(horizontal = 8.dp) - ) - - Row( - modifier = Modifier - .height(IntrinsicSize.Max) - .padding(all = 8.dp) - .fillMaxWidth() - ) { - Canvas( - modifier = Modifier - .fillMaxHeight() - .width(48.dp) - ) { - drawLine( - color = colorPalette.background2, - start = size.center.copy(y = 0f), - end = size.center.copy(y = size.height), - strokeWidth = 2.dp.toPx() - ) - - drawCircle( - color = colorPalette.background2, - center = size.center.copy(y = size.height), - radius = 4.dp.toPx() + ArtistContent( + artist = artist, + youtubeArtist = youtubeArtist, + isLoading = isLoading, + isError = isError, + stateSaver = YouTubeAlbumListSaver, + bookmarkIconContent = bookmarkIconContent, + shareIconContent = shareIconContent, + itemsProvider = { + youtubeArtist + ?.singlesEndpoint + ?.let { endpoint -> + YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result -> + result?.continuation to result?.items + } + } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { albumRoute(album.info?.endpoint?.browseId) } + ) ) + }, + itemShimmer = { + AlbumItemShimmer(thumbnailSizeDp = thumbnailSizeDp) } - - BasicText( - text = description, - style = typography.xxs.secondary.medium.copy( - lineHeight = 24.sp, - textAlign = TextAlign.Justify - ), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - } + ) } + 4 -> ArtistLocalSongsList( + browseId = browseId, + artist = artist, + isLoading = isLoading, + isError = isError, + bookmarkIconContent = bookmarkIconContent, + shareIconContent = shareIconContent + ) } } } } } } - - -private suspend fun fetchArtist(browseId: String): Result? { - return YouTube.artist(browseId) - ?.map { youtubeArtist -> - Artist( - id = browseId, - name = youtubeArtist.name ?: "", - thumbnailUrl = youtubeArtist.thumbnail?.url, - info = youtubeArtist.description, - shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId, - shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId, - radioVideoId = youtubeArtist.radioEndpoint?.videoId, - radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId, - timestamp = System.currentTimeMillis() - ).also(Database::upsert) - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtistList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtistList.kt index 0668205..8ada7f0 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtistList.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeArtistList.kt @@ -181,7 +181,7 @@ fun HomeArtistList( ) BasicText( - text = artist.name, + text = artist.name ?: "", style = typography.xxs.semiBold.center, maxLines = 2, overflow = TextOverflow.Ellipsis diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResult.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResult.kt index 4d2d14d..38c4aa2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResult.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResult.kt @@ -46,14 +46,13 @@ inline fun SearchResult( ) { val (_, typography) = LocalAppearance.current - var items by rememberSaveable(query, filter, stateSaver = stateSaver) { + var items by rememberSaveable(stateSaver = stateSaver) { mutableStateOf(listOf()) } val (continuationResultState, fetch) = produceSaveableRelaunchableOneShotState( initialValue = null, - stateSaver = StringResultSaver, - query, filter + stateSaver = StringResultSaver ) { val token = value?.getOrNull() diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt index 35134f2..0e9362c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.BasicText @@ -380,6 +381,75 @@ fun AlbumItemShimmer( } } +@Composable +fun AlternativeAlbumItem( + album: YouTube.Item.Album, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (_, typography, thumbnailShape) = LocalAppearance.current + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .width(thumbnailSizeDp) + ) { + AsyncImage( + model = album.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(thumbnailSizeDp) + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicText( + text = album.info?.name ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + BasicText( + text = album.year ?: "", + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + ) + } + } +} + +@Composable +fun AlternativeAlbumItemPlaceholder( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .width(thumbnailSizeDp) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextPlaceholder() + TextPlaceholder() + } + } +} + @Composable fun ArtistItem( artist: YouTube.Item.Artist, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyEffect.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyEffect.kt new file mode 100644 index 0000000..f1a1256 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/LazyEffect.kt @@ -0,0 +1,66 @@ +@file:OptIn(InternalComposeApi::class) + +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.ProduceStateScope +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.State +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlin.coroutines.CoroutineContext +import kotlin.experimental.ExperimentalTypeInference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + + + +@Composable +@NonRestartableComposable +fun lazyEffect( + key1: Any?, + block: suspend CoroutineScope.() -> Unit +): () -> Unit { + val applyContext = currentComposer.applyCoroutineContext + + val lazyEffect = remember(key1) { + LazyEffectImpl(applyContext, block) + } + + return lazyEffect::calculate +} + +class LazyEffectImpl( + parentCoroutineContext: CoroutineContext, + private val task: suspend CoroutineScope.() -> Unit +) : RememberObserver { + private val scope = CoroutineScope(parentCoroutineContext) + private var job: Job? = null + + fun calculate() { + if (job == null) { + job = scope.launch(block = task) + } + } + + override fun onRemembered() = Unit + + override fun onForgotten() { + job?.cancel() + job = null + } + + override fun onAbandoned() { + job?.cancel() + job = null + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index 2cd3eb2..2ad759f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -33,6 +33,7 @@ const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" const val homeScreenTabIndexKey = "homeScreenTabIndex" const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" +const val artistScreenTabIndexKey = "artistScreenTabIndex" inline fun > SharedPreferences.getEnum( key: String, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt index 32ec81f..104fcb2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt @@ -6,14 +6,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.ProduceStateScope +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import kotlin.coroutines.CoroutineContext import kotlin.experimental.ExperimentalTypeInference +import kotlin.reflect.KProperty import kotlinx.coroutines.suspendCancellableCoroutine @Composable @@ -123,19 +126,17 @@ fun produceSaveableState( fun produceSaveableRelaunchableOneShotState( initialValue: T, stateSaver: Saver, - key1: Any?, - key2: Any?, @BuilderInference producer: suspend ProduceStateScope.() -> Unit ): Pair, () -> Unit> { val result = rememberSaveable(stateSaver = stateSaver) { mutableStateOf(initialValue) } - var produced by rememberSaveable(key1, key2) { + var produced by rememberSaveable { mutableStateOf(false) } - val relaunchableEffect = relaunchableEffect(key1, key2) { + val relaunchableEffect = relaunchableEffect(Unit) { if (!produced) { ProduceSaveableStateScope(result, coroutineContext).producer() produced = true @@ -148,6 +149,70 @@ fun produceSaveableRelaunchableOneShotState( } } +@Composable +fun produceSaveableRelaunchableOneShotState( + initialValue: T, + stateSaver: Saver, + key1: Any?, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): Pair, () -> Unit> { + val result = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + var produced by rememberSaveable(key1) { + mutableStateOf(false) + } + + val relaunchableEffect = relaunchableEffect(key1) { + if (!produced) { + ProduceSaveableStateScope(result, coroutineContext).producer() + produced = true + } + } + + return result to { + produced = false + relaunchableEffect() + } +} + +@Composable +fun produceSaveableLazyOneShotState( + initialValue: T, + stateSaver: Saver, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + val state = rememberSaveable(stateSaver = stateSaver) { + mutableStateOf(initialValue) + } + + var produced by rememberSaveable { + mutableStateOf(false) + } + + val lazyEffect = lazyEffect(Unit) { + if (!produced) { + ProduceSaveableStateScope(state, coroutineContext).producer() + produced = true + } + } + + val delegate = remember { + object : State { + override val value: T + get() { + if (!produced) { + lazyEffect() + } + return state.value + } + } + } + + return delegate +} + private class ProduceSaveableStateScope( state: MutableState, override val coroutineContext: CoroutineContext diff --git a/youtube-music/build.gradle.kts b/youtube-music/build.gradle.kts index dd07ad5..e2cf347 100644 --- a/youtube-music/build.gradle.kts +++ b/youtube-music/build.gradle.kts @@ -20,4 +20,4 @@ dependencies { implementation(libs.ktor.serialization.json) testImplementation(testLibs.junit) -} \ No newline at end of file +} diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index 5317c1a..c72c669 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -70,6 +70,7 @@ object YouTube { data class BrowseBody( val context: Context, val browseId: String, + val params: String? = null, ) @Serializable @@ -560,7 +561,7 @@ object YouTube { } else { response.body() .continuationContents - .musicShelfContinuation + ?.musicShelfContinuation } } SearchResult( @@ -580,7 +581,7 @@ object YouTube { continuation = musicShelfRenderer ?.continuations ?.firstOrNull() - ?.nextRadioContinuationData + ?.nextContinuationData ?.continuation ) }.recoverIfCancelled() @@ -785,7 +786,7 @@ object YouTube { ?.playlistPanelRenderer ?.continuations ?.getOrNull(0) - ?.nextRadioContinuationData + ?.nextContinuationData ?.continuation, items = (tabs .getOrNull(0) @@ -868,9 +869,9 @@ object YouTube { } else { browse(lyricsBrowseId)?.map { body -> body.contents - .sectionListRenderer + ?.sectionListRenderer ?.contents - ?.first() + ?.firstOrNull() ?.musicDescriptionShelfRenderer ?.description ?.text @@ -895,6 +896,105 @@ object YouTube { }.recoverIfCancelled() } + data class ItemsResult( + val items: List?, + val continuation: String? + ) + + suspend fun items( + browseId: String, + continuation: String?, + block: (MusicResponsiveListItemRenderer) -> T? + ): Result?>? { + return runCatching { + val response = client.post("/youtubei/v1/browse") { + contentType(ContentType.Application.Json) + setBody( + BrowseBody( + browseId = browseId, + context = Context.DefaultWeb + ) + ) + parameter("key", Key) + parameter("prettyPrint", false) + parameter("continuation", continuation) + } + + if (continuation == null) { + response + .body() + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + ?.musicShelfRenderer + } else { + response + .body() + .continuationContents + ?.musicShelfContinuation + }?.let { musicShelfRenderer -> + ItemsResult( + items = musicShelfRenderer + .contents + .mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + .mapNotNull(block), + continuation = musicShelfRenderer + .continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation + ) + } + }.recoverIfCancelled() + } + + suspend fun items2( + browseId: String, + params: String?, + block: (MusicTwoRowItemRenderer) -> T? + ): Result?>? { + return runCatching { + client.post("/youtubei/v1/browse") { + contentType(ContentType.Application.Json) + setBody( + BrowseBody( + browseId = browseId, + context = Context.DefaultWeb, + params = params + ) + ) + parameter("key", Key) + parameter("prettyPrint", false) + } + .body() + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.firstOrNull() + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.firstOrNull() + ?.gridRenderer + ?.let { gridRenderer -> + ItemsResult( + items = gridRenderer + .items + ?.mapNotNull(SectionListRenderer.Content.GridRenderer.Item::musicTwoRowItemRenderer) + ?.mapNotNull(block), + continuation = null + ) + } + }.recoverIfCancelled() + } + data class PlaylistOrAlbum( val title: String?, val authors: List>?, @@ -918,17 +1018,17 @@ object YouTube { songs = songs?.plus( continuationResponse .continuationContents - .musicShelfContinuation + ?.musicShelfContinuation ?.contents ?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(Item.Song.Companion::from) ?: emptyList() ), continuation = continuationResponse .continuationContents - .musicShelfContinuation + ?.musicShelfContinuation ?.continuations ?.firstOrNull() - ?.nextRadioContinuationData + ?.nextContinuationData ?.continuation ).next() } @@ -1003,7 +1103,7 @@ object YouTube { ?.text, songs = body .contents - .singleColumnBrowseResultsRenderer + ?.singleColumnBrowseResultsRenderer ?.tabs ?.firstOrNull() ?.tabRenderer @@ -1021,7 +1121,7 @@ object YouTube { ?.urlCanonical, continuation = body .contents - .singleColumnBrowseResultsRenderer + ?.singleColumnBrowseResultsRenderer ?.tabs ?.firstOrNull() ?.tabRenderer @@ -1032,7 +1132,7 @@ object YouTube { ?.musicShelfRenderer ?.continuations ?.firstOrNull() - ?.nextRadioContinuationData + ?.nextContinuationData ?.continuation ) } @@ -1043,24 +1143,61 @@ object YouTube { val description: String?, val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?, val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, - val radioEndpoint: NavigationEndpoint.Endpoint.Watch? + val radioEndpoint: NavigationEndpoint.Endpoint.Watch?, + val songs: List?, + val songsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val albums: List?, + val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, + val singles: List?, + val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, ) suspend fun artist(browseId: String): Result? { - return browse(browseId)?.map { body -> + return browse(browseId)?.map { response -> + fun findSectionByTitle(text: String): SectionListRenderer.Content? { + return response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.contents + ?.find { content -> + val title = content + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?: content + .musicShelfRenderer + ?.title + + title + ?.runs + ?.firstOrNull() + ?.text == text + } + } + + val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer + val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer + val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer + Artist( - name = body + name = response .header ?.musicImmersiveHeaderRenderer ?.title ?.text, - description = body + description = response .header ?.musicImmersiveHeaderRenderer ?.description ?.text ?.substringBeforeLast("\n\nFrom Wikipedia"), - thumbnail = body + thumbnail = response .header ?.musicImmersiveHeaderRenderer ?.thumbnail @@ -1068,20 +1205,49 @@ object YouTube { ?.thumbnail ?.thumbnails ?.getOrNull(0), - shuffleEndpoint = body + shuffleEndpoint = response .header ?.musicImmersiveHeaderRenderer ?.playButton ?.buttonRenderer ?.navigationEndpoint ?.watchEndpoint, - radioEndpoint = body + radioEndpoint = response .header ?.musicImmersiveHeaderRenderer ?.startRadioButton ?.buttonRenderer ?.navigationEndpoint - ?.watchEndpoint + ?.watchEndpoint, + songs = songsSection + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Item.Song::from), + songsEndpoint = songsSection + ?.bottomEndpoint + ?.browseEndpoint, + albums = albumsSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Item.Album::from), + albumsEndpoint = albumsSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + singles = singlesSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Item.Album::from), + singlesEndpoint = singlesSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, ) } } @@ -1132,7 +1298,7 @@ object YouTube { browse(browseId)?.getOrThrow()?.let { browseResponse -> browseResponse .contents - .sectionListRenderer + ?.sectionListRenderer ?.contents ?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer) ?.map(MusicCarouselShelfRenderer::contents) diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt index a576205..40779d1 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/BrowseResponse.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @OptIn(ExperimentalSerializationApi::class) @Serializable data class BrowseResponse( - val contents: Contents, + val contents: Contents?, val header: Header?, val microformat: Microformat? ) { diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt index 4dbf917..7e6092f 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Continuation.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.json.JsonNames @Serializable data class Continuation( @JsonNames("nextContinuationData", "nextRadioContinuationData") - val nextRadioContinuationData: Data + val nextContinuationData: Data ) { @Serializable data class Data( diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt index abc48f8..c880434 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/ContinuationResponse.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ContinuationResponse( - val continuationContents: ContinuationContents, + val continuationContents: ContinuationContents?, ) { @Serializable data class ContinuationContents( diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt index 6bda074..ea6dca6 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicShelfRenderer.kt @@ -1,9 +1,7 @@ package it.vfsfitvnm.youtubemusic.models -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable -@OptIn(ExperimentalSerializationApi::class) @Serializable data class MusicShelfRenderer( val bottomEndpoint: NavigationEndpoint?, diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt index 7fa9c37..17ae0b5 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt @@ -44,11 +44,12 @@ data class SectionListRenderer( ) { @Serializable data class GridRenderer( - val items: List, + val items: List?, ) { @Serializable data class Item( - val musicNavigationButtonRenderer: MusicNavigationButtonRenderer + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? ) }