Redesign ArtistScreen (#123, #172)

This commit is contained in:
vfsfitvnm
2022-09-30 19:27:34 +02:00
parent cfd369266e
commit 4bc3671be1
25 changed files with 2107 additions and 381 deletions

View File

@@ -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')"
]
}
}

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.MediaItem
import androidx.room.AutoMigration import androidx.room.AutoMigration
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.DeleteColumn
import androidx.room.DeleteTable import androidx.room.DeleteTable
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy 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.DetailedSongWithContentLength
import it.vfsfitvnm.vimusic.models.Event import it.vfsfitvnm.vimusic.models.Event
import it.vfsfitvnm.vimusic.models.Format import it.vfsfitvnm.vimusic.models.Format
import it.vfsfitvnm.vimusic.models.PartialArtist
import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.PlaylistPreview
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
@@ -150,16 +152,16 @@ interface Database {
@Query("SELECT * FROM Artist WHERE id = :id") @Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?> fun artist(id: String): Flow<Artist?>
@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<List<Artist>> fun artistsByNameDesc(): Flow<List<Artist>>
@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<List<Artist>> fun artistsByNameAsc(): Flow<List<Artist>>
@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<List<Artist>> fun artistsByRowIdDesc(): Flow<List<Artist>>
@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<List<Artist>> fun artistsByRowIdAsc(): Flow<List<Artist>>
fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> { fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> {
@@ -378,7 +380,7 @@ interface Database {
name = artistName, name = artistName,
thumbnailUrl = null, thumbnailUrl = null,
info = null, info = null,
timestamp = null, timestamp = null
).also(::insert) ).also(::insert)
} }
} }
@@ -419,6 +421,9 @@ interface Database {
@Upsert @Upsert
fun upsert(artist: Artist) fun upsert(artist: Artist)
@Upsert(Artist::class)
fun upsert(artist: PartialArtist)
@Delete @Delete
fun delete(searchQuery: SearchQuery) fun delete(searchQuery: SearchQuery)
@@ -449,7 +454,7 @@ interface Database {
views = [ views = [
SortedSongPlaylistMap::class SortedSongPlaylistMap::class
], ],
version = 20, version = 21,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
@@ -468,6 +473,7 @@ interface Database {
AutoMigration(from = 17, to = 18), AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19), AutoMigration(from = 18, to = 19),
AutoMigration(from = 19, to = 20), AutoMigration(from = 19, to = 20),
AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -601,6 +607,14 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
it.execSQL("ALTER TABLE Song_new RENAME TO Song;") 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 @TypeConverters

View File

@@ -8,13 +8,9 @@ import androidx.room.PrimaryKey
@Entity @Entity
data class Artist( data class Artist(
@PrimaryKey val id: String, @PrimaryKey val id: String,
val name: String, val name: String?,
val thumbnailUrl: String?, val thumbnailUrl: String?,
val info: String?, val info: String?,
val shuffleVideoId: String? = null,
val shufflePlaylistId: String? = null,
val radioVideoId: String? = null,
val radioPlaylistId: String? = null,
val timestamp: Long?, val timestamp: Long?,
val bookmarkedAt: Long? = null, val bookmarkedAt: Long? = null,
) )

View File

@@ -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,
)

View File

@@ -3,7 +3,6 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.Playlist
object ArtistSaver : Saver<Artist, List<Any?>> { object ArtistSaver : Saver<Artist, List<Any?>> {
override fun SaverScope.save(value: Artist): List<Any?> = listOf( override fun SaverScope.save(value: Artist): List<Any?> = listOf(
@@ -11,10 +10,6 @@ object ArtistSaver : Saver<Artist, List<Any?>> {
value.name, value.name,
value.thumbnailUrl, value.thumbnailUrl,
value.info, value.info,
value.shuffleVideoId,
value.shufflePlaylistId,
value.radioVideoId,
value.radioPlaylistId,
value.timestamp, value.timestamp,
value.bookmarkedAt, value.bookmarkedAt,
) )
@@ -24,11 +19,7 @@ object ArtistSaver : Saver<Artist, List<Any?>> {
name = value[1] as String, name = value[1] as String,
thumbnailUrl = value[2] as String?, thumbnailUrl = value[2] as String?,
info = value[3] as String?, info = value[3] as String?,
shuffleVideoId = value[4] as String?, timestamp = value[4] as Long?,
shufflePlaylistId = value[5] as String?, bookmarkedAt = value[5] as Long?,
radioVideoId = value[6] as String?,
radioPlaylistId = value[7] as String?,
timestamp = value[8] as Long?,
bookmarkedAt = value[9] as Long?,
) )
} }

View File

@@ -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<YouTube.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Artist): List<Any?> = 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<Any?>) = YouTube.Artist(
name = value[0] as String?,
description = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore),
shuffleEndpoint = (value[3] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore),
radioEndpoint = (value[4] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore),
songs = (value[5] as List<List<Any?>>?)?.let(YouTubeSongListSaver::restore),
songsEndpoint = (value[6] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
albums = (value[7] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
albumsEndpoint = (value[8] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
singles = (value[9] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
singlesEndpoint = (value[10] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
)
}

View File

@@ -2,13 +2,14 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.savers.YouTubeThumbnailSaver.save
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeArtistSaver : Saver<YouTube.Item.Artist, List<Any?>> { object YouTubeArtistSaver : Saver<YouTube.Item.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Artist): List<Any?> = listOf( override fun SaverScope.save(value: YouTube.Item.Artist): List<Any?> = listOf(
value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } }, value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.subscribersCountText, value.subscribersCountText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
) )
override fun restore(value: List<Any?>) = YouTube.Item.Artist( override fun restore(value: List<Any?>) = YouTube.Item.Artist(

View File

@@ -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
)
}

View File

@@ -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 <T : YouTube.Item> ArtistContent(
artist: Artist?,
youtubeArtist: YouTube.Artist?,
isLoading: Boolean,
isError: Boolean,
stateSaver: ListSaver<T, List<Any?>>,
crossinline itemsProvider: suspend (String?) -> Result<Pair<String?, List<T>?>>?,
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<String?>(),
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()
}
}
}
}

View File

@@ -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()
}
}
}
}
}
}
}

View File

@@ -1,76 +1,373 @@
package it.vfsfitvnm.vimusic.ui.screens.artist package it.vfsfitvnm.vimusic.ui.screens.artist
import android.content.Intent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText 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.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.painterResource import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.themed.Header import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder 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.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.styling.shimmer 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.asMediaItem
import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.forcePlay
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.secondary import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.thumbnail import it.vfsfitvnm.vimusic.utils.thumbnail
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ExperimentalFoundationApi
@Composable @Composable
fun ArtistOverview( 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 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
)
} }

View File

@@ -1,87 +1,182 @@
package it.vfsfitvnm.vimusic.ui.screens.artist package it.vfsfitvnm.vimusic.ui.screens.artist
import android.content.Intent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.interaction.MutableInteractionSource
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.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.material.ripple.rememberRipple
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.route.RouteHandler
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.PartialArtist
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.query 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.components.themed.Scaffold
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes 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.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px 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.asMediaItem
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning import it.vfsfitvnm.vimusic.utils.produceSaveableLazyOneShotState
import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.rememberPreference
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.thumbnail
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
fun ArtistScreen(browseId: String) { fun ArtistScreen(browseId: String) {
val saveableStateHolder = rememberSaveableStateHolder() val saveableStateHolder = rememberSaveableStateHolder()
val (tabIndex, onTabIndexChanged) = rememberSaveable { val (tabIndex, onTabIndexChanged) = rememberPreference(
mutableStateOf(0) 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) { RouteHandler(listenToGlobalEmitter = true) {
globalRoutes() globalRoutes()
host { 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( Scaffold(
topIconButtonId = R.drawable.chevron_back, topIconButtonId = R.drawable.chevron_back,
onTopIconButtonClick = pop, onTopIconButtonClick = pop,
@@ -92,273 +187,151 @@ fun ArtistScreen(browseId: String) {
Item(1, "Songs", R.drawable.musical_notes) Item(1, "Songs", R.drawable.musical_notes)
Item(2, "Albums", R.drawable.disc) Item(2, "Albums", R.drawable.disc)
Item(3, "Singles", R.drawable.disc) Item(3, "Singles", R.drawable.disc)
Item(4, "Library", R.drawable.library)
} }
) { currentTabIndex -> ) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
ArtistOverview(browseId = browseId) when (currentTabIndex) {
} 0 -> ArtistOverview(
} artist = artist,
} youtubeArtist = youtubeArtist,
} isLoading = isLoading,
} isError = isError,
bookmarkIconContent = bookmarkIconContent,
@ExperimentalAnimationApi shareIconContent = shareIconContent,
@Composable onAlbumClick = { albumRoute(it) },
fun ArtistScreen2(browseId: String) { onViewAllSongsClick = { onTabIndexChanged(1) },
val lazyListState = rememberLazyListState() onViewAllAlbumsClick = { onTabIndexChanged(2) },
onViewAllSinglesClick = { onTabIndexChanged(3) },
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)
) )
1 -> {
val binder = LocalPlayerServiceBinder.current
val thumbnailSizeDp = Dimensions.thumbnails.song
val thumbnailSizePx = thumbnailSizeDp.px
BasicText( ArtistContent(
text = artist.name, artist = artist,
style = typography.l.semiBold, youtubeArtist = youtubeArtist,
modifier = Modifier isLoading = isLoading,
.padding(vertical = 8.dp, horizontal = 16.dp) isError = isError,
) stateSaver = YouTubeSongListSaver,
bookmarkIconContent = bookmarkIconContent,
Row( shareIconContent = shareIconContent,
horizontalArrangement = Arrangement.spacedBy(32.dp), itemsProvider = { continuation ->
modifier = Modifier youtubeArtist
.padding(horizontal = 16.dp, vertical = 8.dp) ?.songsEndpoint
) { ?.browseId
Image( ?.let { browseId ->
painter = painterResource(R.drawable.shuffle), YouTube.items(browseId, continuation, YouTube.Item.Song::from)?.map { result ->
contentDescription = null, result?.continuation to result?.items
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.playRadio(
NavigationEndpoint.Endpoint.Watch(
videoId = artist.shuffleVideoId,
playlistId = artist.shufflePlaylistId
)
)
query {
runBlocking {
fetchArtist(browseId)
} }
} }
} },
.padding(all = 8.dp) itemContent = { song ->
.size(20.dp) 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( ArtistContent(
painter = painterResource(R.drawable.radio), artist = artist,
contentDescription = null, youtubeArtist = youtubeArtist,
colorFilter = ColorFilter.tint(colorPalette.text), isLoading = isLoading,
modifier = Modifier isError = isError,
.clickable { stateSaver = YouTubeAlbumListSaver,
binder?.playRadio( bookmarkIconContent = bookmarkIconContent,
NavigationEndpoint.Endpoint.Watch( shareIconContent = shareIconContent,
videoId = artist.radioVideoId itemsProvider = {
?: artist.shuffleVideoId, youtubeArtist
playlistId = artist.radioPlaylistId ?.albumsEndpoint
) ?.let { endpoint ->
) YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result ->
result?.continuation to result?.items
query {
runBlocking {
fetchArtist(browseId)
} }
} }
} },
.padding(all = 8.dp) itemContent = { album ->
.size(20.dp) AlbumItem(
) album = album,
} thumbnailSizePx = thumbnailSizePx,
} ?: artistResult?.exceptionOrNull()?.let { throwable -> thumbnailSizeDp = thumbnailSizeDp,
// LoadingOrError( modifier = Modifier
// errorMessage = throwable.javaClass.canonicalName, .clickable(
// onRetry = { indication = rememberRipple(bounded = true),
// query { interactionSource = remember { MutableInteractionSource() },
// runBlocking { onClick = { albumRoute(album.info?.endpoint?.browseId) }
// 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)
) )
},
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 -> ArtistContent(
item { artist = artist,
Column( youtubeArtist = youtubeArtist,
verticalArrangement = Arrangement.spacedBy(8.dp), isLoading = isLoading,
modifier = Modifier isError = isError,
.background(colorPalette.background0) stateSaver = YouTubeAlbumListSaver,
.fillMaxWidth() bookmarkIconContent = bookmarkIconContent,
.padding(horizontal = 8.dp) shareIconContent = shareIconContent,
.padding(top = 32.dp) itemsProvider = {
) { youtubeArtist
BasicText( ?.singlesEndpoint
text = "Information", ?.let { endpoint ->
style = typography.m.semiBold, YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result ->
modifier = Modifier result?.continuation to result?.items
.padding(horizontal = 8.dp) }
) }
},
Row( itemContent = { album ->
modifier = Modifier AlbumItem(
.height(IntrinsicSize.Max) album = album,
.padding(all = 8.dp) thumbnailSizePx = thumbnailSizePx,
.fillMaxWidth() thumbnailSizeDp = thumbnailSizeDp,
) { modifier = Modifier
Canvas( .clickable(
modifier = Modifier indication = rememberRipple(bounded = true),
.fillMaxHeight() interactionSource = remember { MutableInteractionSource() },
.width(48.dp) onClick = { albumRoute(album.info?.endpoint?.browseId) }
) { )
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()
) )
},
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<Artist>? {
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)
}
}

View File

@@ -181,7 +181,7 @@ fun HomeArtistList(
) )
BasicText( BasicText(
text = artist.name, text = artist.name ?: "",
style = typography.xxs.semiBold.center, style = typography.xxs.semiBold.center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis

View File

@@ -46,14 +46,13 @@ inline fun <T : YouTube.Item> SearchResult(
) { ) {
val (_, typography) = LocalAppearance.current val (_, typography) = LocalAppearance.current
var items by rememberSaveable(query, filter, stateSaver = stateSaver) { var items by rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(listOf()) mutableStateOf(listOf())
} }
val (continuationResultState, fetch) = produceSaveableRelaunchableOneShotState( val (continuationResultState, fetch) = produceSaveableRelaunchableOneShotState(
initialValue = null, initialValue = null,
stateSaver = StringResultSaver, stateSaver = StringResultSaver
query, filter
) { ) {
val token = value?.getOrNull() val token = value?.getOrNull()

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText 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 @Composable
fun ArtistItem( fun ArtistItem(
artist: YouTube.Item.Artist, artist: YouTube.Item.Artist,

View File

@@ -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
}
}

View File

@@ -33,6 +33,7 @@ const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics"
const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen"
const val homeScreenTabIndexKey = "homeScreenTabIndex" const val homeScreenTabIndexKey = "homeScreenTabIndex"
const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex"
const val artistScreenTabIndexKey = "artistScreenTabIndex"
inline fun <reified T : Enum<T>> SharedPreferences.getEnum( inline fun <reified T : Enum<T>> SharedPreferences.getEnum(
key: String, key: String,

View File

@@ -6,14 +6,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProduceStateScope import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.experimental.ExperimentalTypeInference import kotlin.experimental.ExperimentalTypeInference
import kotlin.reflect.KProperty
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@Composable @Composable
@@ -123,19 +126,17 @@ fun <T> produceSaveableState(
fun <T> produceSaveableRelaunchableOneShotState( fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T, initialValue: T,
stateSaver: Saver<T, out Any>, stateSaver: Saver<T, out Any>,
key1: Any?,
key2: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit @BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> Unit> { ): Pair<State<T>, () -> Unit> {
val result = rememberSaveable(stateSaver = stateSaver) { val result = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue) mutableStateOf(initialValue)
} }
var produced by rememberSaveable(key1, key2) { var produced by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
val relaunchableEffect = relaunchableEffect(key1, key2) { val relaunchableEffect = relaunchableEffect(Unit) {
if (!produced) { if (!produced) {
ProduceSaveableStateScope(result, coroutineContext).producer() ProduceSaveableStateScope(result, coroutineContext).producer()
produced = true produced = true
@@ -148,6 +149,70 @@ fun <T> produceSaveableRelaunchableOneShotState(
} }
} }
@Composable
fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
key1: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> 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 <T> produceSaveableLazyOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
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<T> {
override val value: T
get() {
if (!produced) {
lazyEffect()
}
return state.value
}
}
}
return delegate
}
private class ProduceSaveableStateScope<T>( private class ProduceSaveableStateScope<T>(
state: MutableState<T>, state: MutableState<T>,
override val coroutineContext: CoroutineContext override val coroutineContext: CoroutineContext

View File

@@ -20,4 +20,4 @@ dependencies {
implementation(libs.ktor.serialization.json) implementation(libs.ktor.serialization.json)
testImplementation(testLibs.junit) testImplementation(testLibs.junit)
} }

View File

@@ -70,6 +70,7 @@ object YouTube {
data class BrowseBody( data class BrowseBody(
val context: Context, val context: Context,
val browseId: String, val browseId: String,
val params: String? = null,
) )
@Serializable @Serializable
@@ -560,7 +561,7 @@ object YouTube {
} else { } else {
response.body<ContinuationResponse>() response.body<ContinuationResponse>()
.continuationContents .continuationContents
.musicShelfContinuation ?.musicShelfContinuation
} }
} }
SearchResult( SearchResult(
@@ -580,7 +581,7 @@ object YouTube {
continuation = musicShelfRenderer continuation = musicShelfRenderer
?.continuations ?.continuations
?.firstOrNull() ?.firstOrNull()
?.nextRadioContinuationData ?.nextContinuationData
?.continuation ?.continuation
) )
}.recoverIfCancelled() }.recoverIfCancelled()
@@ -785,7 +786,7 @@ object YouTube {
?.playlistPanelRenderer ?.playlistPanelRenderer
?.continuations ?.continuations
?.getOrNull(0) ?.getOrNull(0)
?.nextRadioContinuationData ?.nextContinuationData
?.continuation, ?.continuation,
items = (tabs items = (tabs
.getOrNull(0) .getOrNull(0)
@@ -868,9 +869,9 @@ object YouTube {
} else { } else {
browse(lyricsBrowseId)?.map { body -> browse(lyricsBrowseId)?.map { body ->
body.contents body.contents
.sectionListRenderer ?.sectionListRenderer
?.contents ?.contents
?.first() ?.firstOrNull()
?.musicDescriptionShelfRenderer ?.musicDescriptionShelfRenderer
?.description ?.description
?.text ?.text
@@ -895,6 +896,105 @@ object YouTube {
}.recoverIfCancelled() }.recoverIfCancelled()
} }
data class ItemsResult<T : Item>(
val items: List<T>?,
val continuation: String?
)
suspend fun <T : Item> items(
browseId: String,
continuation: String?,
block: (MusicResponsiveListItemRenderer) -> T?
): Result<ItemsResult<T>?>? {
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<BrowseResponse>()
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.firstOrNull()
?.tabRenderer
?.content
?.sectionListRenderer
?.contents
?.firstOrNull()
?.musicShelfRenderer
} else {
response
.body<ContinuationResponse>()
.continuationContents
?.musicShelfContinuation
}?.let { musicShelfRenderer ->
ItemsResult(
items = musicShelfRenderer
.contents
.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
.mapNotNull(block),
continuation = musicShelfRenderer
.continuations
?.firstOrNull()
?.nextContinuationData
?.continuation
)
}
}.recoverIfCancelled()
}
suspend fun <T : Item> items2(
browseId: String,
params: String?,
block: (MusicTwoRowItemRenderer) -> T?
): Result<ItemsResult<T>?>? {
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<BrowseResponse>()
.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( data class PlaylistOrAlbum(
val title: String?, val title: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
@@ -918,17 +1018,17 @@ object YouTube {
songs = songs?.plus( songs = songs?.plus(
continuationResponse continuationResponse
.continuationContents .continuationContents
.musicShelfContinuation ?.musicShelfContinuation
?.contents ?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList() ?.mapNotNull(Item.Song.Companion::from) ?: emptyList()
), ),
continuation = continuationResponse continuation = continuationResponse
.continuationContents .continuationContents
.musicShelfContinuation ?.musicShelfContinuation
?.continuations ?.continuations
?.firstOrNull() ?.firstOrNull()
?.nextRadioContinuationData ?.nextContinuationData
?.continuation ?.continuation
).next() ).next()
} }
@@ -1003,7 +1103,7 @@ object YouTube {
?.text, ?.text,
songs = body songs = body
.contents .contents
.singleColumnBrowseResultsRenderer ?.singleColumnBrowseResultsRenderer
?.tabs ?.tabs
?.firstOrNull() ?.firstOrNull()
?.tabRenderer ?.tabRenderer
@@ -1021,7 +1121,7 @@ object YouTube {
?.urlCanonical, ?.urlCanonical,
continuation = body continuation = body
.contents .contents
.singleColumnBrowseResultsRenderer ?.singleColumnBrowseResultsRenderer
?.tabs ?.tabs
?.firstOrNull() ?.firstOrNull()
?.tabRenderer ?.tabRenderer
@@ -1032,7 +1132,7 @@ object YouTube {
?.musicShelfRenderer ?.musicShelfRenderer
?.continuations ?.continuations
?.firstOrNull() ?.firstOrNull()
?.nextRadioContinuationData ?.nextContinuationData
?.continuation ?.continuation
) )
} }
@@ -1043,24 +1143,61 @@ object YouTube {
val description: String?, val description: String?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?, val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?, val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
val radioEndpoint: NavigationEndpoint.Endpoint.Watch? val radioEndpoint: NavigationEndpoint.Endpoint.Watch?,
val songs: List<Item.Song>?,
val songsEndpoint: NavigationEndpoint.Endpoint.Browse?,
val albums: List<Item.Album>?,
val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?,
val singles: List<Item.Album>?,
val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?,
) )
suspend fun artist(browseId: String): Result<Artist>? { suspend fun artist(browseId: String): Result<Artist>? {
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( Artist(
name = body name = response
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.title ?.title
?.text, ?.text,
description = body description = response
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.description ?.description
?.text ?.text
?.substringBeforeLast("\n\nFrom Wikipedia"), ?.substringBeforeLast("\n\nFrom Wikipedia"),
thumbnail = body thumbnail = response
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.thumbnail ?.thumbnail
@@ -1068,20 +1205,49 @@ object YouTube {
?.thumbnail ?.thumbnail
?.thumbnails ?.thumbnails
?.getOrNull(0), ?.getOrNull(0),
shuffleEndpoint = body shuffleEndpoint = response
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.playButton ?.playButton
?.buttonRenderer ?.buttonRenderer
?.navigationEndpoint ?.navigationEndpoint
?.watchEndpoint, ?.watchEndpoint,
radioEndpoint = body radioEndpoint = response
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.startRadioButton ?.startRadioButton
?.buttonRenderer ?.buttonRenderer
?.navigationEndpoint ?.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 -> browse(browseId)?.getOrThrow()?.let { browseResponse ->
browseResponse browseResponse
.contents .contents
.sectionListRenderer ?.sectionListRenderer
?.contents ?.contents
?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer) ?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer)
?.map(MusicCarouselShelfRenderer::contents) ?.map(MusicCarouselShelfRenderer::contents)

View File

@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
data class BrowseResponse( data class BrowseResponse(
val contents: Contents, val contents: Contents?,
val header: Header?, val header: Header?,
val microformat: Microformat? val microformat: Microformat?
) { ) {

View File

@@ -8,7 +8,7 @@ import kotlinx.serialization.json.JsonNames
@Serializable @Serializable
data class Continuation( data class Continuation(
@JsonNames("nextContinuationData", "nextRadioContinuationData") @JsonNames("nextContinuationData", "nextRadioContinuationData")
val nextRadioContinuationData: Data val nextContinuationData: Data
) { ) {
@Serializable @Serializable
data class Data( data class Data(

View File

@@ -7,7 +7,7 @@ import kotlinx.serialization.json.JsonNames
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
data class ContinuationResponse( data class ContinuationResponse(
val continuationContents: ContinuationContents, val continuationContents: ContinuationContents?,
) { ) {
@Serializable @Serializable
data class ContinuationContents( data class ContinuationContents(

View File

@@ -1,9 +1,7 @@
package it.vfsfitvnm.youtubemusic.models package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
data class MusicShelfRenderer( data class MusicShelfRenderer(
val bottomEndpoint: NavigationEndpoint?, val bottomEndpoint: NavigationEndpoint?,

View File

@@ -44,11 +44,12 @@ data class SectionListRenderer(
) { ) {
@Serializable @Serializable
data class GridRenderer( data class GridRenderer(
val items: List<Item>, val items: List<Item>?,
) { ) {
@Serializable @Serializable
data class Item( data class Item(
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?,
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?
) )
} }