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.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<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>>
@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>>
@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>>
@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 artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> {
@ -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

View file

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

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.SaverScope
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.Playlist
object ArtistSaver : Saver<Artist, List<Any?>> {
override fun SaverScope.save(value: Artist): List<Any?> = listOf(
@ -11,10 +10,6 @@ object ArtistSaver : Saver<Artist, List<Any?>> {
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<Artist, List<Any?>> {
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?,
)
}

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.SaverScope
import it.vfsfitvnm.vimusic.savers.YouTubeThumbnailSaver.save
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeArtistSaver : Saver<YouTube.Item.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Artist): List<Any?> = 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<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
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
)
}

View file

@ -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<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(
text = artist.name,
text = artist.name ?: "",
style = typography.xxs.semiBold.center,
maxLines = 2,
overflow = TextOverflow.Ellipsis

View file

@ -46,14 +46,13 @@ inline fun <T : YouTube.Item> 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()

View file

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

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 homeScreenTabIndexKey = "homeScreenTabIndex"
const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex"
const val artistScreenTabIndexKey = "artistScreenTabIndex"
inline fun <reified T : Enum<T>> SharedPreferences.getEnum(
key: String,

View file

@ -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 <T> produceSaveableState(
fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
key1: Any?,
key2: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> 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 <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>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext

View file

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

View file

@ -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<ContinuationResponse>()
.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<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(
val title: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
@ -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<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>? {
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)

View file

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

View file

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

View file

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

View file

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

View file

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