Start working on QuickPicks screen

This commit is contained in:
vfsfitvnm 2022-09-28 21:46:56 +02:00
parent 7a3c0ca110
commit 33778b33dd
37 changed files with 1354 additions and 272 deletions

View file

@ -0,0 +1,670 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "b9a9bb1674c7c50be2fab48de5afed43",
"entities": [
{
"tableName": "Song",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `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": true
},
{
"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 NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "info",
"columnName": "info",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shuffleVideoId",
"columnName": "shuffleVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shufflePlaylistId",
"columnName": "shufflePlaylistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioVideoId",
"columnName": "radioVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioPlaylistId",
"columnName": "radioPlaylistId",
"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, 'b9a9bb1674c7c50be2fab48de5afed43')"
]
}
}

View file

@ -37,6 +37,7 @@ import it.vfsfitvnm.vimusic.models.Album
import it.vfsfitvnm.vimusic.models.Artist import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.DetailedSong 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.Format import it.vfsfitvnm.vimusic.models.Format
import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.models.PlaylistPreview
@ -288,6 +289,19 @@ interface Database {
@Query("SELECT EXISTS(SELECT 1 FROM Playlist WHERE browseId = :browseId)") @Query("SELECT EXISTS(SELECT 1 FROM Playlist WHERE browseId = :browseId)")
fun isImportedPlaylist(browseId: String): Flow<Boolean> fun isImportedPlaylist(browseId: String): Flow<Boolean>
@Transaction
@Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(playTime / ((:now - timestamp) / 86400000.0)) LIMIT 1")
@RewriteQueriesToDropUnusedColumns
fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
// @Transaction
// @Query("SELECT songId FROM Event GROUP BY songId ORDER BY SUM(playTime / ((:now - timestamp) / 86400000.0)) LIMIT 1")
// @RewriteQueriesToDropUnusedColumns
// fun trending(now: Long = System.currentTimeMillis()): Flow<DetailedSong?>
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(event: Event)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(format: Format) fun insert(format: Format)
@ -427,11 +441,12 @@ interface Database {
SearchQuery::class, SearchQuery::class,
QueuedMediaItem::class, QueuedMediaItem::class,
Format::class, Format::class,
Event::class,
], ],
views = [ views = [
SortedSongPlaylistMap::class SortedSongPlaylistMap::class
], ],
version = 18, version = 19,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
@ -448,6 +463,7 @@ interface Database {
AutoMigration(from = 15, to = 16), AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17), AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18), AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View file

@ -0,0 +1,25 @@
package it.vfsfitvnm.vimusic.models
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Immutable
@Entity(
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["id"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class Event(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(index = true) val songId: String,
val timestamp: Long,
val playTime: Long
)

View file

@ -26,6 +26,6 @@ object DetailedSongSaver : Saver<DetailedSong, List<Any?>> {
thumbnailUrl = value[4] as String?, thumbnailUrl = value[4] as String?,
totalPlayTimeMs = value[5] as Long, totalPlayTimeMs = value[5] as Long,
albumId = value[6] as String?, albumId = value[6] as String?,
artists = InfoListSaver.restore(value[7] as List<List<String>>) artists = (value[7] as List<List<String>>?)?.let(InfoListSaver::restore)
) )
} }

View file

@ -8,9 +8,6 @@ object InfoSaver : Saver<Info, List<String>> {
override fun SaverScope.save(value: Info): List<String> = listOf(value.id, value.name) override fun SaverScope.save(value: Info): List<String> = listOf(value.id, value.name)
override fun restore(value: List<String>): Info? { override fun restore(value: List<String>): Info? {
return if (value.size == 2) Info( return if (value.size == 2) Info(id = value[0], name = value[1]) else null
id = value[0],
name = value[1],
) else null
} }
} }

View file

@ -0,0 +1,13 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
fun <Original, Saveable : Any> nullableSaver(saver: Saver<Original, Saveable>) =
object : Saver<Original?, Saveable> {
override fun SaverScope.save(value: Original?): Saveable? =
value?.let { with(saver) { save(it) } }
override fun restore(value: Saveable): Original? =
saver.restore(value)
}

View file

@ -3,8 +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
interface ResultSaver<Original, Saveable> : Saver<Result<Original>?, Pair<Saveable?, Throwable?>>
fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) = fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) =
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> { object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
override fun restore(value: Pair<Saveable?, Throwable?>) = override fun restore(value: Pair<Saveable?, Throwable?>) =

View file

@ -6,15 +6,15 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeAlbumSaver : Saver<YouTube.Item.Album, List<Any?>> { object YouTubeAlbumSaver : Saver<YouTube.Item.Album, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Album): List<Any?> = listOf( override fun SaverScope.save(value: YouTube.Item.Album): List<Any?> = listOf(
with(YouTubeBrowseInfoSaver) { save(value.info) }, value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } }, value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
value.year, value.year,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
) )
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Album( override fun restore(value: List<Any?>) = YouTube.Item.Album(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>), info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore), authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
year = value[2] as String?, year = value[2] as String?,
thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore) thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -6,13 +6,13 @@ 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(
with(YouTubeBrowseInfoSaver) { save(value.info) }, value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.subscribersCountText, value.subscribersCountText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
) )
override fun restore(value: List<Any?>) = YouTube.Item.Artist( override fun restore(value: List<Any?>) = YouTube.Item.Artist(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>), info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
subscribersCountText = value[1] as String?, subscribersCountText = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore) thumbnail = (value[2] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)
) )

View file

@ -8,11 +8,11 @@ import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
object YouTubeBrowseInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Browse>, List<Any?>> { object YouTubeBrowseInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Browse>, List<Any?>> {
override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Browse>) = listOf( override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Browse>) = listOf(
value.name, value.name,
with(YouTubeBrowseEndpointSaver) { value.endpoint?.let { save(it) } } value.endpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } }
) )
override fun restore(value: List<Any?>) = YouTube.Info( override fun restore(value: List<Any?>) = YouTube.Info(
name = value[0] as String, name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore) endpoint = (value[1] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore)
) )
} }

View file

@ -6,14 +6,14 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubePlaylistSaver : Saver<YouTube.Item.Playlist, List<Any?>> { object YouTubePlaylistSaver : Saver<YouTube.Item.Playlist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Playlist): List<Any?> = listOf( override fun SaverScope.save(value: YouTube.Item.Playlist): List<Any?> = listOf(
with(YouTubeBrowseInfoSaver) { save(value.info) }, value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
with(YouTubeBrowseInfoSaver) { value.channel?.let { save(it) } }, value.channel?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.songCount, value.songCount,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
) )
override fun restore(value: List<Any?>) = YouTube.Item.Playlist( override fun restore(value: List<Any?>) = YouTube.Item.Playlist(
info = YouTubeBrowseInfoSaver.restore(value[0] as List<Any?>), info = (value[0] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
channel = (value[1] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore), channel = (value[1] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
songCount = value[2] as Int?, songCount = value[2] as Int?,
thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore) thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -0,0 +1,22 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeRelatedSaver : Saver<YouTube.Related, List<Any?>> {
override fun SaverScope.save(value: YouTube.Related): List<Any?> = listOf(
value.songs?.let { with(YouTubeSongListSaver) { save(it) } },
value.playlists?.let { with(YouTubePlaylistListSaver) { save(it) } },
value.albums?.let { with(YouTubeAlbumListSaver) { save(it) } },
value.artists?.let { with(YouTubeArtistListSaver) { save(it) } },
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Related(
songs = (value[0] as List<List<Any?>>?)?.let(YouTubeSongListSaver::restore),
playlists = (value[1] as List<List<Any?>>?)?.let(YouTubePlaylistListSaver::restore),
albums = (value[2] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
artists = (value[3] as List<List<Any?>>?)?.let(YouTubeArtistListSaver::restore),
)
}

View file

@ -6,17 +6,17 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeSongSaver : Saver<YouTube.Item.Song, List<Any?>> { object YouTubeSongSaver : Saver<YouTube.Item.Song, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Song): List<Any?> = listOf( override fun SaverScope.save(value: YouTube.Item.Song): List<Any?> = listOf(
with(YouTubeWatchInfoSaver) { save(value.info) }, value.info?.let { with(YouTubeWatchInfoSaver) { save(it) } },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } }, value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
with(YouTubeBrowseInfoSaver) { value.album?.let { save(it) } }, value.album?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.durationText, value.durationText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
) )
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Song( override fun restore(value: List<Any?>) = YouTube.Item.Song(
info = YouTubeWatchInfoSaver.restore(value[0] as List<Any?>), info = (value[0] as List<Any?>?)?.let(YouTubeWatchInfoSaver::restore),
authors = YouTubeBrowseInfoListSaver.restore(value[1] as List<List<Any?>>), authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
album = (value[2] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore), album = (value[2] as List<Any?>?)?.let(YouTubeBrowseInfoSaver::restore),
durationText = value[3] as String?, durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore) thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -6,17 +6,17 @@ import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeVideoSaver : Saver<YouTube.Item.Video, List<Any?>> { object YouTubeVideoSaver : Saver<YouTube.Item.Video, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Video): List<Any?> = listOf( override fun SaverScope.save(value: YouTube.Item.Video): List<Any?> = listOf(
with(YouTubeWatchInfoSaver) { save(value.info) }, value.info?.let { with(YouTubeWatchInfoSaver) { save(it) } },
with(YouTubeBrowseInfoListSaver) { value.authors?.let { save(it) } }, value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } },
value.viewsText, value.viewsText,
value.durationText, value.durationText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } } value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
) )
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Item.Video( override fun restore(value: List<Any?>) = YouTube.Item.Video(
info = YouTubeWatchInfoSaver.restore(value[0] as List<Any?>), info = (value[0] as List<Any?>?)?.let(YouTubeWatchInfoSaver::restore),
authors = YouTubeBrowseInfoListSaver.restore(value[1] as List<List<Any?>>), authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
viewsText = value[2] as String?, viewsText = value[2] as String?,
durationText = value[3] as String?, durationText = value[3] as String?,
thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore) thumbnail = (value[4] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore)

View file

@ -8,11 +8,11 @@ import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
object YouTubeWatchInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Watch>, List<Any?>> { object YouTubeWatchInfoSaver : Saver<YouTube.Info<NavigationEndpoint.Endpoint.Watch>, List<Any?>> {
override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Watch>) = listOf( override fun SaverScope.save(value: YouTube.Info<NavigationEndpoint.Endpoint.Watch>) = listOf(
value.name, value.name,
with(YouTubeWatchEndpointSaver) { value.endpoint?.let { save(it) } } value.endpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } },
) )
override fun restore(value: List<Any?>) = YouTube.Info( override fun restore(value: List<Any?>) = YouTube.Info(
name = value[0] as String, name = value[0] as String?,
endpoint = (value[1] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore) endpoint = (value[1] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore)
) )
} }

View file

@ -65,6 +65,7 @@ import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize
import it.vfsfitvnm.vimusic.models.Event
import it.vfsfitvnm.vimusic.models.QueuedMediaItem import it.vfsfitvnm.vimusic.models.QueuedMediaItem
import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.utils.InvincibleService import it.vfsfitvnm.vimusic.utils.InvincibleService
@ -285,11 +286,23 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
val totalPlayTimeMs = playbackStats.totalPlayTimeMs val totalPlayTimeMs = playbackStats.totalPlayTimeMs
if (totalPlayTimeMs > 2000) { if (totalPlayTimeMs > 5000) {
query { query {
Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs)
} }
} }
if (totalPlayTimeMs > 30000) {
query {
Database.insert(
Event(
songId = mediaItem.mediaId,
timestamp = System.currentTimeMillis(),
playTime = totalPlayTimeMs
)
)
}
}
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {

View file

@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.components.themed package it.vfsfitvnm.vimusic.ui.components.themed
import android.content.Intent
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
@ -57,7 +58,6 @@ import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.shareAsYouTubeSong
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -241,7 +241,13 @@ fun BaseMediaItemMenu(
onGoToAlbum = albumRoute::global, onGoToAlbum = albumRoute::global,
onGoToArtist = artistRoute::global, onGoToArtist = artistRoute::global,
onShare = { onShare = {
context.shareAsYouTubeSong(mediaItem) val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}")
}
context.startActivity(Intent.createChooser(sendIntent, null))
}, },
modifier = modifier modifier = modifier
) )

View file

@ -95,7 +95,7 @@ fun AlbumOverview(
title = youtubeAlbum.title, title = youtubeAlbum.title,
thumbnailUrl = youtubeAlbum.thumbnail?.url, thumbnailUrl = youtubeAlbum.thumbnail?.url,
year = youtubeAlbum.year, year = youtubeAlbum.year,
authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, authorsText = youtubeAlbum.authors?.joinToString("") { it.name ?: "" },
shareUrl = youtubeAlbum.url, shareUrl = youtubeAlbum.url,
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
), ),

View file

@ -271,7 +271,7 @@ fun ArtistScreen2(browseId: String) {
) { index, song -> ) { index, song ->
SongItem( SongItem(
song = song, song = song,
thumbnailSize = songThumbnailSizePx, thumbnailSizePx = songThumbnailSizePx,
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlayAtIndex( binder?.player?.forcePlayAtIndex(
@ -351,7 +351,7 @@ private suspend fun fetchArtist(browseId: String): Result<Artist>? {
?.map { youtubeArtist -> ?.map { youtubeArtist ->
Artist( Artist(
id = browseId, id = browseId,
name = youtubeArtist.name, name = youtubeArtist.name ?: "",
thumbnailUrl = youtubeArtist.thumbnail?.url, thumbnailUrl = youtubeArtist.thumbnail?.url,
info = youtubeArtist.description, info = youtubeArtist.description,
shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId, shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId,

View file

@ -103,7 +103,7 @@ fun BuiltInPlaylistSongList(builtInPlaylist: BuiltInPlaylist) {
) { index, song -> ) { index, song ->
SongItem( SongItem(
song = song, song = song,
thumbnailSize = thumbnailSize, thumbnailSizePx = thumbnailSize,
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlayAtIndex( binder?.player?.forcePlayAtIndex(

View file

@ -58,8 +58,8 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalFoundationApi @ExperimentalFoundationApi
@Composable @Composable
fun HomePlaylistList( fun HomePlaylistList(
onBuiltInPlaylistClicked: (BuiltInPlaylist) -> Unit, onBuiltInPlaylist: (BuiltInPlaylist) -> Unit,
onPlaylistClicked: (Playlist) -> Unit, onPlaylistClick: (Playlist) -> Unit,
) { ) {
val (colorPalette) = LocalAppearance.current val (colorPalette) = LocalAppearance.current
@ -186,7 +186,7 @@ fun HomePlaylistList(
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Favorites) } onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }
) )
) )
} }
@ -200,7 +200,7 @@ fun HomePlaylistList(
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Offline) } onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }
) )
.animateItemPlacement() .animateItemPlacement()
) )
@ -216,7 +216,7 @@ fun HomePlaylistList(
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { onPlaylistClicked(playlistPreview.playlist) } onClick = { onPlaylistClick(playlistPreview.playlist) }
) )
.animateItemPlacement() .animateItemPlacement()
) )

View file

@ -86,30 +86,27 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
tabIndex = tabIndex, tabIndex = tabIndex,
onTabChanged = onTabChanged, onTabChanged = onTabChanged,
tabColumnContent = { Item -> tabColumnContent = { Item ->
Item(0, "Songs", R.drawable.musical_notes) Item(0, "Quick picks", R.drawable.sparkles)
Item(1, "Playlists", R.drawable.playlist) Item(1, "Songs", R.drawable.musical_notes)
Item(2, "Artists", R.drawable.person) Item(2, "Playlists", R.drawable.playlist)
Item(3, "Albums", R.drawable.disc) Item(3, "Artists", R.drawable.person)
Item(4, "Albums", R.drawable.disc)
}, },
primaryIconButtonId = R.drawable.search, primaryIconButtonId = R.drawable.search,
onPrimaryIconButtonClick = { searchRoute("") } onPrimaryIconButtonClick = { searchRoute("") }
) { currentTabIndex -> ) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
when (currentTabIndex) { when (currentTabIndex) {
1 -> HomePlaylistList( 0 -> QuickPicks(
onBuiltInPlaylistClicked = { builtInPlaylistRoute(it) }, onAlbumClick = { albumRoute(it) },
onPlaylistClicked = { localPlaylistRoute(it.id) }
) )
1 -> HomeSongList()
2 -> HomeArtistList( 2 -> HomePlaylistList(
onArtistClick = { artistRoute(it.id) } onBuiltInPlaylist = { builtInPlaylistRoute(it) },
onPlaylistClick = { localPlaylistRoute(it.id) }
) )
3 -> HomeArtistList(onArtistClick = { artistRoute(it.id) })
3 -> HomeAlbumList( 4 -> HomeAlbumList(onAlbumClick = { albumRoute(it.id) })
onAlbumClick = { albumRoute(it.id) }
)
else -> HomeSongList()
} }
} }
} }

View file

@ -162,7 +162,7 @@ fun HomeSongList() {
) { index, song -> ) { index, song ->
SongItem( SongItem(
song = song, song = song,
thumbnailSize = thumbnailSize, thumbnailSizePx = thumbnailSize,
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlayAtIndex(items.map(DetailedSong::asMediaItem), index) binder?.player?.forcePlayAtIndex(items.map(DetailedSong::asMediaItem), index)

View file

@ -0,0 +1,214 @@
package it.vfsfitvnm.vimusic.ui.screens.home
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
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.savers.DetailedSongSaver
import it.vfsfitvnm.vimusic.savers.YouTubeRelatedSaver
import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
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.AlbumItem
import it.vfsfitvnm.vimusic.ui.views.SmallSongItem
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState
import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun QuickPicks(
onAlbumClick: (String) -> Unit
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val trending by produceSaveableState(
initialValue = null,
stateSaver = nullableSaver(DetailedSongSaver),
) {
Database.trending()
.flowOn(Dispatchers.IO)
.filterNotNull()
.distinctUntilChanged()
.collect { value = it }
}
val relatedResult by produceSaveableOneShotState(
initialValue = null,
stateSaver = resultSaver(nullableSaver(YouTubeRelatedSaver)),
trending?.id
) {
println("trendingVideoId: ${trending?.id}")
trending?.id?.let { trendingVideoId ->
value = YouTube.related(trendingVideoId)?.map { related ->
related?.copy(
albums = related.albums?.map { album ->
album.copy(
authors = trending?.artists?.map { info ->
YouTube.Info(
name = info.name,
endpoint = NavigationEndpoint.Endpoint.Browse(
browseId = info.id,
params = null,
browseEndpointContextSupportedConfigs = null
)
)
}
)
}
)
}
}
}
val songThumbnailSizePx = Dimensions.thumbnails.song.px
val albumThumbnailSizeDp = 108.dp
val albumThumbnailSizePx = albumThumbnailSizeDp.px
// val itemInHorizontalGridWidth = (LocalConfiguration.current.screenWidthDp.dp) * 0.8f
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Header(title = "Quick picks")
}
trending?.let { song ->
item(key = song.id) {
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
}
)
}
}
relatedResult?.getOrNull()?.let { related ->
items(
items = related.songs?.take(6) ?: emptyList(),
key = YouTube.Item::key
) { 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)
)
},
)
}
item(
key = "albums",
contentType = "LazyRow"
) {
LazyRow {
items(
items = related.albums ?: emptyList(),
key = YouTube.Item::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
.fillMaxWidth()
)
}
}
}
items(
items = related.songs?.drop(6) ?: emptyList(),
key = YouTube.Item::key
) { 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)
)
},
)
}
}
}
}

View file

@ -165,11 +165,7 @@ fun LocalPlaylistSongList(
transaction { transaction {
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map { YouTube.playlist(browseId)?.map { it.next() }
it.next()
}?.map { playlist ->
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
} }
}?.getOrNull()?.let { remotePlaylist -> }?.getOrNull()?.let { remotePlaylist ->
Database.clearPlaylist(playlistId) Database.clearPlaylist(playlistId)
@ -222,7 +218,7 @@ fun LocalPlaylistSongList(
) { index, song -> ) { index, song ->
SongItem( SongItem(
song = song, song = song,
thumbnailSize = thumbnailSize, thumbnailSizePx = thumbnailSize,
onClick = { onClick = {
playlistWithSongs?.songs?.map(DetailedSong::asMediaItem) playlistWithSongs?.songs?.map(DetailedSong::asMediaItem)
?.let { mediaItems -> ?.let { mediaItems ->

View file

@ -135,7 +135,7 @@ fun Lyrics(
)?.map { it?.value } )?.map { it?.value }
} else { } else {
YouTube.next(mediaId, null) YouTube.next(mediaId, null)
?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() } ?.map { nextResult -> nextResult.lyrics()?.getOrNull() }
}?.map { newLyrics -> }?.map { newLyrics ->
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "") onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
state = state.copy(isLoading = false) state = state.copy(isLoading = false)

View file

@ -149,7 +149,7 @@ fun PlayerBottomSheet(
SongItem( SongItem(
mediaItem = window.mediaItem, mediaItem = window.mediaItem,
thumbnailSize = thumbnailSize, thumbnailSizePx = thumbnailSize,
onClick = { onClick = {
if (isPlayingThisMediaItem) { if (isPlayingThisMediaItem) {
if (shouldBePlaying) { if (shouldBePlaying) {

View file

@ -81,11 +81,7 @@ fun PlaylistSongList(
stateSaver = resultSaver(YouTubePlaylistOrAlbumSaver), stateSaver = resultSaver(YouTubePlaylistOrAlbumSaver),
) { ) {
value = withContext(Dispatchers.IO) { value = withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map { YouTube.playlist(browseId)?.map { it.next() }
it.next()
}?.map { playlist ->
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
} }
} }
@ -202,8 +198,8 @@ fun PlaylistSongList(
itemsIndexed(items = playlist.songs ?: emptyList()) { index, song -> itemsIndexed(items = playlist.songs ?: emptyList()) { index, song ->
SongItem( SongItem(
title = song.info.name, title = song.info?.name,
authors = (song.authors ?: playlist.authors)?.joinToString("") { it.name }, authors = (song.authors ?: playlist.authors)?.joinToString("") { it.name ?: "" },
durationText = song.durationText, durationText = song.durationText,
onClick = { onClick = {
playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems -> playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems ->

View file

@ -100,7 +100,7 @@ fun LocalSongSearch(
) { song -> ) { song ->
SongItem( SongItem(
song = song, song = song,
thumbnailSize = thumbnailSize, thumbnailSizePx = thumbnailSize,
onClick = { onClick = {
val mediaItem = song.asMediaItem val mediaItem = song.asMediaItem
binder?.stopRadio() binder?.stopRadio()

View file

@ -92,7 +92,7 @@ inline fun <T : YouTube.Item> SearchResult(
items( items(
items = items, items = items,
key = { it.key!! }, key = YouTube.Item::key,
itemContent = itemContent itemContent = itemContent
) )

View file

@ -102,7 +102,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlay(song.asMediaItem) binder?.player?.forcePlay(song.asMediaItem)
binder?.setupRadio(song.info.endpoint) binder?.setupRadio(song.info?.endpoint)
} }
) )
}, },
@ -130,7 +130,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { albumRoute(album.info.endpoint?.browseId) } onClick = { albumRoute(album.info?.endpoint?.browseId) }
) )
) )
@ -159,7 +159,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { artistRoute(artist.info.endpoint?.browseId) } onClick = { artistRoute(artist.info?.endpoint?.browseId) }
) )
) )
}, },
@ -186,7 +186,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlay(video.asMediaItem) binder?.player?.forcePlay(video.asMediaItem)
binder?.setupRadio(video.info.endpoint) binder?.setupRadio(video.info?.endpoint)
} }
) )
}, },
@ -217,7 +217,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { playlistRoute(playlist.info.endpoint?.browseId) } onClick = { playlistRoute(playlist.info?.endpoint?.browseId) }
) )
) )
}, },

View file

@ -40,7 +40,7 @@ import it.vfsfitvnm.vimusic.utils.thumbnail
@NonRestartableComposable @NonRestartableComposable
fun SongItem( fun SongItem(
mediaItem: MediaItem, mediaItem: MediaItem,
thumbnailSize: Int, thumbnailSizePx: Int,
onClick: () -> Unit, onClick: () -> Unit,
menuContent: @Composable () -> Unit, menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -48,7 +48,7 @@ fun SongItem(
trailingContent: (@Composable () -> Unit)? = null trailingContent: (@Composable () -> Unit)? = null
) { ) {
SongItem( SongItem(
thumbnailModel = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSize), thumbnailModel = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
title = mediaItem.mediaMetadata.title!!.toString(), title = mediaItem.mediaMetadata.title!!.toString(),
authors = mediaItem.mediaMetadata.artist.toString(), authors = mediaItem.mediaMetadata.artist.toString(),
durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?", durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?",
@ -65,7 +65,7 @@ fun SongItem(
@NonRestartableComposable @NonRestartableComposable
fun SongItem( fun SongItem(
song: DetailedSong, song: DetailedSong,
thumbnailSize: Int, thumbnailSizePx: Int,
onClick: () -> Unit, onClick: () -> Unit,
menuContent: @Composable () -> Unit, menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -73,7 +73,7 @@ fun SongItem(
trailingContent: (@Composable () -> Unit)? = null trailingContent: (@Composable () -> Unit)? = null
) { ) {
SongItem( SongItem(
thumbnailModel = song.thumbnailUrl?.thumbnail(thumbnailSize), thumbnailModel = song.thumbnailUrl?.thumbnail(thumbnailSizePx),
title = song.title, title = song.title,
authors = song.artistsText ?: "", authors = song.artistsText ?: "",
durationText = song.durationText, durationText = song.durationText,
@ -90,8 +90,8 @@ fun SongItem(
@NonRestartableComposable @NonRestartableComposable
fun SongItem( fun SongItem(
thumbnailModel: Any?, thumbnailModel: Any?,
title: String, title: String?,
authors: String, authors: String?,
durationText: String?, durationText: String?,
onClick: () -> Unit, onClick: () -> Unit,
menuContent: @Composable () -> Unit, menuContent: @Composable () -> Unit,
@ -131,7 +131,7 @@ fun SongItem(
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
fun SongItem( fun SongItem(
title: String, title: String?,
authors: String?, authors: String?,
durationText: String?, durationText: String?,
onClick: () -> Unit, onClick: () -> Unit,
@ -167,7 +167,7 @@ fun SongItem(
.weight(1f) .weight(1f)
) { ) {
BasicText( BasicText(
text = title, text = title ?: "",
style = typography.xs.semiBold, style = typography.xs.semiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,

View file

@ -79,8 +79,8 @@ fun SmallSongItem(
) { ) {
SongItem( SongItem(
thumbnailModel = song.thumbnail?.size(thumbnailSizePx), thumbnailModel = song.thumbnail?.size(thumbnailSizePx),
title = song.info.name, title = song.info?.name,
authors = song.authors?.joinToString("") { it.name } ?: "", authors = song.authors?.joinToString("") { it.name ?: "" },
durationText = song.durationText, durationText = song.durationText,
onClick = onClick, onClick = onClick,
menuContent = { menuContent = {
@ -148,14 +148,14 @@ fun VideoItem(
Column { Column {
BasicText( BasicText(
text = video.info.name, text = video.info?.name ?: "",
style = typography.xs.semiBold, style = typography.xs.semiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
BasicText( BasicText(
text = video.authors?.joinToString("") { it.name } ?: "", text = video.authors?.joinToString("") { it.name ?: "" } ?: "",
style = typography.xs.semiBold.secondary, style = typography.xs.semiBold.secondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -252,7 +252,7 @@ fun PlaylistItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText( BasicText(
text = playlist.info.name, text = playlist.info?.name ?: "",
style = typography.xs.semiBold, style = typography.xs.semiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -322,14 +322,14 @@ fun AlbumItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText( BasicText(
text = album.info.name, text = album.info?.name ?: "",
style = typography.xs.semiBold, style = typography.xs.semiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
BasicText( BasicText(
text = album.authors?.joinToString("") { it.name } ?: "", text = album.authors?.joinToString("") { it.name ?: "" } ?: "",
style = typography.xs.semiBold.secondary, style = typography.xs.semiBold.secondary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -406,7 +406,7 @@ fun ArtistItem(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText( BasicText(
text = artist.info.name, text = artist.info?.name ?: "",
style = typography.xs.semiBold, style = typography.xs.semiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis

View file

@ -1,7 +1,5 @@
package it.vfsfitvnm.vimusic.utils package it.vfsfitvnm.vimusic.utils
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
@ -10,37 +8,23 @@ import androidx.media3.common.MediaMetadata
import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
fun Context.shareAsYouTubeSong(mediaItem: MediaItem) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}")
}
startActivity(Intent.createChooser(sendIntent, null))
}
val YouTube.Item.Song.asMediaItem: MediaItem val YouTube.Item.Song.asMediaItem: MediaItem
get() = MediaItem.Builder() get() = MediaItem.Builder()
.also { .setMediaId(key)
// println("$this") .setUri(key)
// println(info.endpoint?.videoId) .setCustomCacheKey(key)
}
.setMediaId(info.endpoint!!.videoId!!)
.setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId)
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(info.name) .setTitle(info?.name)
.setArtist(authors?.joinToString("") { it.name }) .setArtist(authors?.joinToString("") { it.name ?: "" })
.setAlbumTitle(album?.name) .setAlbumTitle(album?.name)
.setArtworkUri(thumbnail?.url?.toUri()) .setArtworkUri(thumbnail?.url?.toUri())
.setExtras( .setExtras(
bundleOf( bundleOf(
"videoId" to info.endpoint!!.videoId, "videoId" to key,
"albumId" to album?.endpoint?.browseId, "albumId" to album?.endpoint?.browseId,
"durationText" to durationText, "durationText" to durationText,
"artistNames" to authors?.filter { it.endpoint != null }?.map { it.name }, "artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name },
"artistIds" to authors?.mapNotNull { it.endpoint?.browseId }, "artistIds" to authors?.mapNotNull { it.endpoint?.browseId },
) )
) )
@ -50,19 +34,19 @@ val YouTube.Item.Song.asMediaItem: MediaItem
val YouTube.Item.Video.asMediaItem: MediaItem val YouTube.Item.Video.asMediaItem: MediaItem
get() = MediaItem.Builder() get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId!!) .setMediaId(key)
.setUri(info.endpoint!!.videoId) .setUri(key)
.setCustomCacheKey(info.endpoint!!.videoId) .setCustomCacheKey(key)
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(info.name) .setTitle(info?.name)
.setArtist(authors?.joinToString("") { it.name }) .setArtist(authors?.joinToString("") { it.name ?: "" })
.setArtworkUri(thumbnail?.url?.toUri()) .setArtworkUri(thumbnail?.url?.toUri())
.setExtras( .setExtras(
bundleOf( bundleOf(
"videoId" to info.endpoint!!.videoId, "videoId" to key,
"durationText" to durationText, "durationText" to durationText,
"artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.map { it.name } else null, "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null,
"artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null, "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null,
) )
) )

View file

@ -20,13 +20,16 @@ import it.vfsfitvnm.youtubemusic.models.BrowseResponse
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
import it.vfsfitvnm.youtubemusic.models.GetQueueResponse import it.vfsfitvnm.youtubemusic.models.GetQueueResponse
import it.vfsfitvnm.youtubemusic.models.GetSearchSuggestionsResponse import it.vfsfitvnm.youtubemusic.models.GetSearchSuggestionsResponse
import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import it.vfsfitvnm.youtubemusic.models.NextResponse import it.vfsfitvnm.youtubemusic.models.NextResponse
import it.vfsfitvnm.youtubemusic.models.PlayerResponse import it.vfsfitvnm.youtubemusic.models.PlayerResponse
import it.vfsfitvnm.youtubemusic.models.Runs import it.vfsfitvnm.youtubemusic.models.Runs
import it.vfsfitvnm.youtubemusic.models.SearchResponse import it.vfsfitvnm.youtubemusic.models.SearchResponse
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
import it.vfsfitvnm.youtubemusic.models.ThumbnailRenderer import it.vfsfitvnm.youtubemusic.models.ThumbnailRenderer
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -36,7 +39,7 @@ import kotlinx.serialization.json.Json
object YouTube { object YouTube {
private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
val client = HttpClient(OkHttp) { private val client = HttpClient(OkHttp) {
BrowserUserAgent() BrowserUserAgent()
expectSuccess = true expectSuccess = true
@ -162,37 +165,34 @@ object YouTube {
} }
data class Info<T : NavigationEndpoint.Endpoint>( data class Info<T : NavigationEndpoint.Endpoint>(
val name: String, val name: String?,
val endpoint: T? val endpoint: T?
) { ) {
companion object { @Suppress("UNCHECKED_CAST")
inline fun <reified T : NavigationEndpoint.Endpoint> from(run: Runs.Run): Info<T> { constructor(run: Runs.Run) : this(
return Info( name = run.text,
name = run.text, endpoint = run.navigationEndpoint?.endpoint as T?
endpoint = run.navigationEndpoint?.endpoint as T? )
)
}
}
} }
sealed class Item { sealed class Item {
abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
abstract val key: String? abstract val key: String
data class Song( data class Song(
val info: Info<NavigationEndpoint.Endpoint.Watch>, val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?, val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val durationText: String?, val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() { ) : Item() {
override val key: String? override val key: String
get() = info.endpoint?.videoId get() = info!!.endpoint!!.videoId!!
companion object : FromMusicShelfRendererContent<Song> { companion object {
val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Song { fun from(content: MusicShelfRenderer.Content): Song? {
val (mainRuns, otherRuns) = content.runs val (mainRuns, otherRuns) = content.runs
// Possible configurations: // Possible configurations:
@ -210,21 +210,22 @@ object YouTube {
?.browseEndpoint ?.browseEndpoint
?.type == "MUSIC_PAGE_TYPE_ALBUM" ?.type == "MUSIC_PAGE_TYPE_ALBUM"
} }
?.let(Info.Companion::from) ?.let(::Info)
return Song( return Song(
info = Info.from(mainRuns.first()), info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns authors = otherRuns
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2) .getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
?.map(Info.Companion::from) ?.map(::Info),
?: emptyList(),
album = album, album = album,
durationText = otherRuns durationText = otherRuns
.lastOrNull() .lastOrNull()
?.firstOrNull()?.text, ?.firstOrNull()?.text,
thumbnail = content thumbnail = content
.thumbnail .thumbnail
) ).takeIf { it.info?.endpoint?.videoId != null }
} }
fun from(renderer: MusicResponsiveListItemRenderer): Song? { fun from(renderer: MusicResponsiveListItemRenderer): Song? {
@ -236,15 +237,15 @@ object YouTube {
?.text ?.text
?.runs ?.runs
?.getOrNull(0) ?.getOrNull(0)
?.let { Info.from(it) } ?: return null, ?.let(::Info),
authors = renderer authors = renderer
.flexColumns .flexColumns
.getOrNull(1) .getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer ?.musicResponsiveListItemFlexColumnRenderer
?.text ?.text
?.runs ?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) } ?.map<Runs.Run, Info<NavigationEndpoint.Endpoint.Browse>>(::Info)
?.takeIf { it.isNotEmpty() }, ?.takeIf(List<Any>::isNotEmpty),
durationText = renderer durationText = renderer
.fixedColumns .fixedColumns
?.getOrNull(0) ?.getOrNull(0)
@ -260,53 +261,55 @@ object YouTube {
?.text ?.text
?.runs ?.runs
?.firstOrNull() ?.firstOrNull()
?.let { Info.from(it) }, ?.let(::Info),
thumbnail = renderer thumbnail = renderer
.thumbnail .thumbnail
?.musicThumbnailRenderer ?.musicThumbnailRenderer
?.thumbnail ?.thumbnail
?.thumbnails ?.thumbnails
?.firstOrNull() ?.firstOrNull()
) ).takeIf { it.info?.endpoint?.videoId != null }
} }
} }
} }
data class Video( data class Video(
val info: Info<NavigationEndpoint.Endpoint.Watch>, val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val viewsText: String?, val viewsText: String?,
val durationText: String?, val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() { ) : Item() {
override val key: String? override val key: String
get() = info.endpoint?.videoId get() = info!!.endpoint!!.videoId!!
val isOfficialMusicVideo: Boolean val isOfficialMusicVideo: Boolean
get() = info get() = info
.endpoint ?.endpoint
?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig ?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
val isUserGeneratedContent: Boolean val isUserGeneratedContent: Boolean
get() = info get() = info
.endpoint ?.endpoint
?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig ?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC"
companion object : FromMusicShelfRendererContent<Video> { companion object {
val Filter = Filter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") val Filter = Filter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Video { fun from(content: MusicShelfRenderer.Content): Video? {
val (mainRuns, otherRuns) = content.runs val (mainRuns, otherRuns) = content.runs
return Video( return Video(
info = Info.from(mainRuns.first()), info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns authors = otherRuns
.getOrNull(otherRuns.lastIndex - 2) .getOrNull(otherRuns.lastIndex - 2)
?.map(Info.Companion::from), ?.map(::Info),
viewsText = otherRuns viewsText = otherRuns
.getOrNull(otherRuns.lastIndex - 1) .getOrNull(otherRuns.lastIndex - 1)
?.firstOrNull() ?.firstOrNull()
@ -317,31 +320,31 @@ object YouTube {
?.text, ?.text,
thumbnail = content thumbnail = content
.thumbnail .thumbnail
) ).takeIf { it.info?.endpoint?.videoId != null }
} }
} }
} }
data class Album( data class Album(
val info: Info<NavigationEndpoint.Endpoint.Browse>, val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?, val year: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() { ) : Item() {
override val key: String? override val key: String
get() = info.endpoint?.browseId get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Album> { companion object {
val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Album { fun from(content: MusicShelfRenderer.Content): Album? {
val (mainRuns, otherRuns) = content.runs val (mainRuns, otherRuns) = content.runs
return Album( return Album(
info = Info( info = Info(
name = mainRuns name = mainRuns
.first() .firstOrNull()
.text, ?.text,
endpoint = content endpoint = content
.musicResponsiveListItemRenderer .musicResponsiveListItemRenderer
.navigationEndpoint .navigationEndpoint
@ -349,37 +352,59 @@ object YouTube {
), ),
authors = otherRuns authors = otherRuns
.getOrNull(otherRuns.lastIndex - 1) .getOrNull(otherRuns.lastIndex - 1)
?.map(Info.Companion::from), ?.map(::Info),
year = otherRuns year = otherRuns
.getOrNull(otherRuns.lastIndex) .getOrNull(otherRuns.lastIndex)
?.firstOrNull() ?.firstOrNull()
?.text, ?.text,
thumbnail = content thumbnail = content
.thumbnail .thumbnail
) ).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Album? {
return Album(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
authors = null,
year = renderer
.subtitle
.runs
.lastOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
} }
} }
} }
data class Artist( data class Artist(
val info: Info<NavigationEndpoint.Endpoint.Browse>, val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val subscribersCountText: String?, val subscribersCountText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() { ) : Item() {
override val key: String? override val key: String
get() = info.endpoint?.browseId get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Artist> { companion object {
val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Artist { fun from(content: MusicShelfRenderer.Content): Artist? {
val (mainRuns, otherRuns) = content.runs val (mainRuns, otherRuns) = content.runs
return Artist( return Artist(
info = Info( info = Info(
name = mainRuns name = mainRuns
.first() .firstOrNull()
.text, ?.text,
endpoint = content endpoint = content
.musicResponsiveListItemRenderer .musicResponsiveListItemRenderer
.navigationEndpoint .navigationEndpoint
@ -391,22 +416,43 @@ object YouTube {
?.text, ?.text,
thumbnail = content thumbnail = content
.thumbnail .thumbnail
) ).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Artist? {
return Artist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
subscribersCountText = renderer
.subtitle
.runs
.firstOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
} }
} }
} }
data class Playlist( data class Playlist(
val info: Info<NavigationEndpoint.Endpoint.Browse>, val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val channel: Info<NavigationEndpoint.Endpoint.Browse>?, val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
val songCount: Int?, val songCount: Int?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() { ) : Item() {
override val key: String? override val key: String
get() = info.endpoint?.browseId get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Playlist> { companion object {
override fun from(content: MusicShelfRenderer.Content): Playlist { fun from(content: MusicShelfRenderer.Content): Playlist? {
val (mainRuns, otherRuns) = content.runs val (mainRuns, otherRuns) = content.runs
return Playlist( return Playlist(
@ -422,7 +468,7 @@ object YouTube {
channel = otherRuns channel = otherRuns
.firstOrNull() .firstOrNull()
?.firstOrNull() ?.firstOrNull()
?.let { Info.from(it) }, ?.let(::Info),
songCount = otherRuns songCount = otherRuns
.lastOrNull() .lastOrNull()
?.firstOrNull() ?.firstOrNull()
@ -432,7 +478,36 @@ object YouTube {
?.toIntOrNull(), ?.toIntOrNull(),
thumbnail = content thumbnail = content
.thumbnail .thumbnail
) ).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Playlist? {
return Playlist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
channel = renderer
.subtitle
.runs
.getOrNull(2)
?.let(::Info),
songCount = renderer
.subtitle
.runs
.getOrNull(4)
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
} }
} }
} }
@ -445,15 +520,11 @@ object YouTube {
val Filter = Filter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") val Filter = Filter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
} }
interface FromMusicShelfRendererContent<out T : Item> {
fun from(content: MusicShelfRenderer.Content): T
}
@JvmInline @JvmInline
value class Filter(val value: String) value class Filter(val value: String)
} }
class SearchResult(val items: List<Item>, val continuation: String?) class SearchResult(val items: List<Item>?, val continuation: String?)
suspend fun search( suspend fun search(
query: String, query: String,
@ -495,7 +566,7 @@ object YouTube {
SearchResult( SearchResult(
items = musicShelfRenderer items = musicShelfRenderer
?.contents ?.contents
?.map( ?.mapNotNull(
when (filter) { when (filter) {
Item.Song.Filter.value -> Item.Song.Companion::from Item.Song.Filter.value -> Item.Song.Companion::from
Item.Album.Filter.value -> Item.Album.Companion::from Item.Album.Filter.value -> Item.Album.Companion::from
@ -505,7 +576,7 @@ object YouTube {
Item.FeaturedPlaylist.Filter.value -> Item.Playlist.Companion::from Item.FeaturedPlaylist.Filter.value -> Item.Playlist.Companion::from
else -> error("Unknown filter: $filter") else -> error("Unknown filter: $filter")
} }
) ?: emptyList(), ),
continuation = musicShelfRenderer continuation = musicShelfRenderer
?.continuations ?.continuations
?.firstOrNull() ?.firstOrNull()
@ -623,7 +694,7 @@ object YouTube {
info = Info( info = Info(
name = renderer name = renderer
.title .title
?.text ?: return@let null, ?.text,
endpoint = renderer endpoint = renderer
.navigationEndpoint .navigationEndpoint
.watchEndpoint .watchEndpoint
@ -632,14 +703,13 @@ object YouTube {
.longBylineText .longBylineText
?.splitBySeparator() ?.splitBySeparator()
?.getOrNull(0) ?.getOrNull(0)
?.map { Info.from(it) } ?.map(::Info),
?: emptyList(),
album = renderer album = renderer
.longBylineText .longBylineText
?.splitBySeparator() ?.splitBySeparator()
?.getOrNull(1) ?.getOrNull(1)
?.getOrNull(0) ?.getOrNull(0)
?.let { Info.from(it) }, ?.let(::Info),
thumbnail = renderer thumbnail = renderer
.thumbnail .thumbnail
.thumbnails .thumbnails
@ -647,7 +717,7 @@ object YouTube {
durationText = renderer durationText = renderer
.lengthText .lengthText
?.text ?.text
) ).takeIf { it.info?.endpoint?.videoId != null }
} }
} }
}.recoverIfCancelled() }.recoverIfCancelled()
@ -663,16 +733,6 @@ object YouTube {
)?.map { it?.firstOrNull() } )?.map { it?.firstOrNull() }
} }
suspend fun queue(playlistId: String): Result<List<Item.Song>?>? {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = null,
playlistId = playlistId
)
)
}
suspend fun next( suspend fun next(
videoId: String?, videoId: String?,
playlistId: String?, playlistId: String?,
@ -759,7 +819,7 @@ object YouTube {
info = Info( info = Info(
name = renderer name = renderer
.title .title
?.text ?: return@mapNotNull null, ?.text,
endpoint = renderer endpoint = renderer
.navigationEndpoint .navigationEndpoint
.watchEndpoint .watchEndpoint
@ -768,14 +828,13 @@ object YouTube {
.longBylineText .longBylineText
?.splitBySeparator() ?.splitBySeparator()
?.getOrNull(0) ?.getOrNull(0)
?.map { run -> Info.from(run) } ?.map(::Info),
?: emptyList(),
album = renderer album = renderer
.longBylineText .longBylineText
?.splitBySeparator() ?.splitBySeparator()
?.getOrNull(1) ?.getOrNull(1)
?.getOrNull(0) ?.getOrNull(0)
?.let { run -> Info.from(run) }, ?.let(::Info),
thumbnail = renderer thumbnail = renderer
.thumbnail .thumbnail
.thumbnails .thumbnails
@ -783,24 +842,14 @@ object YouTube {
durationText = renderer durationText = renderer
.lengthText .lengthText
?.text ?.text
) ).takeIf { it.info?.endpoint?.videoId != null }
}, },
lyrics = NextResult.Lyrics( lyricsBrowseId = tabs
browseId = tabs .getOrNull(1)
.getOrNull(1) ?.tabRenderer
?.tabRenderer ?.endpoint
?.endpoint ?.browseEndpoint
?.browseEndpoint ?.browseId,
?.browseId
),
related = NextResult.Related(
browseId = tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
)
) )
}.recoverIfCancelled() }.recoverIfCancelled()
} }
@ -811,32 +860,23 @@ object YouTube {
val params: String? = null, val params: String? = null,
val playlistSetVideoId: String? = null, val playlistSetVideoId: String? = null,
val items: List<Item.Song>?, val items: List<Item.Song>?,
val lyrics: Lyrics?, val lyricsBrowseId: String?
val related: Related?,
) { ) {
class Lyrics( suspend fun lyrics(): Result<String?>? {
val browseId: String?, return if (lyricsBrowseId == null) {
) { Result.success(null)
suspend fun text(): Result<String?>? { } else {
return if (browseId == null) { browse(lyricsBrowseId)?.map { body ->
Result.success(null) body.contents
} else { .sectionListRenderer
browse(browseId)?.map { body -> ?.contents
body.contents ?.first()
.sectionListRenderer ?.musicDescriptionShelfRenderer
?.contents ?.description
?.first() ?.text
?.musicDescriptionShelfRenderer
?.description
?.text
}
} }
} }
} }
class Related(
val browseId: String?,
)
} }
suspend fun browse(browseId: String): Result<BrowseResponse>? { suspend fun browse(browseId: String): Result<BrowseResponse>? {
@ -875,12 +915,14 @@ object YouTube {
parameter("continuation", continuation) parameter("continuation", continuation)
}.body<ContinuationResponse>().let { continuationResponse -> }.body<ContinuationResponse>().let { continuationResponse ->
copy( copy(
songs = songs?.plus(continuationResponse songs = songs?.plus(
.continuationContents continuationResponse
.musicShelfContinuation .continuationContents
?.contents .musicShelfContinuation
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.contents
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()), ?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()
),
continuation = continuationResponse continuation = continuationResponse
.continuationContents .continuationContents
.musicShelfContinuation .musicShelfContinuation
@ -897,7 +939,7 @@ object YouTube {
suspend fun album(browseId: String): Result<PlaylistOrAlbum>? { suspend fun album(browseId: String): Result<PlaylistOrAlbum>? {
return playlistOrAlbum(browseId)?.map { album -> return playlistOrAlbum(browseId)?.map { album ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
playlistOrAlbum("VL$playlistId")?.getOrNull()?.let { playlist -> playlistOrAlbum("VL$playlistId")?.getOrNull()?.let { playlist ->
album.copy(songs = playlist.songs) album.copy(songs = playlist.songs)
} }
@ -950,7 +992,7 @@ object YouTube {
?.subtitle ?.subtitle
?.splitBySeparator() ?.splitBySeparator()
?.getOrNull(1) ?.getOrNull(1)
?.map { Info.from(it) }, ?.map(::Info),
year = body year = body
.header .header
?.musicDetailHeaderRenderer ?.musicDetailHeaderRenderer
@ -972,9 +1014,7 @@ object YouTube {
?.musicShelfRenderer ?.musicShelfRenderer
?.contents ?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?.mapNotNull(Item.Song.Companion::from),
// ?.filter { it.info.endpoint != null }
,
url = body url = body
.microformat .microformat
?.microformatDataRenderer ?.microformatDataRenderer
@ -999,7 +1039,7 @@ object YouTube {
} }
data class Artist( data class Artist(
val name: String, val name: String?,
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?,
@ -1013,7 +1053,7 @@ object YouTube {
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
?.title ?.title
?.text ?: "Unknown", ?.text,
description = body description = body
.header .header
?.musicImmersiveHeaderRenderer ?.musicImmersiveHeaderRenderer
@ -1045,4 +1085,100 @@ object YouTube {
) )
} }
} }
data class Related(
val songs: List<Item.Song>? = null,
val playlists: List<Item.Playlist>? = null,
val albums: List<Item.Album>? = null,
val artists: List<Item.Artist>? = null,
)
suspend fun related(videoId: String): Result<Related?>? {
return runCatching {
val body = client.post("/youtubei/v1/next") {
contentType(ContentType.Application.Json)
setBody(
NextBody(
context = Context.DefaultWeb,
videoId = videoId,
playlistId = null,
isAudioOnly = true,
tunerSettingValue = "AUTOMIX_SETTING_NORMAL",
watchEndpointMusicSupportedConfigs = NextBody.WatchEndpointMusicSupportedConfigs(
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
),
index = 0,
playlistSetVideoId = null,
params = null,
continuation = null
)
)
parameter("key", Key)
parameter("prettyPrint", false)
}.body<NextResponse>()
body
.contents
.singleColumnMusicWatchNextResultsRenderer
.tabbedRenderer
.watchNextTabbedResultsRenderer
.tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
?.let { browseId ->
browse(browseId)?.getOrThrow()?.let { browseResponse ->
browseResponse
.contents
.sectionListRenderer
?.contents
?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer)
?.map(MusicCarouselShelfRenderer::contents)
}
}?.let { contents ->
Related(
songs = contents.find { items ->
items.firstOrNull()?.musicResponsiveListItemRenderer != null
}?.mapNotNull { content ->
Item.Song.from(content.musicResponsiveListItemRenderer!!)
},
playlists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Playlist.Companion::from),
albums = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ALBUM"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Album.Companion::from),
artists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ARTIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Artist.Companion::from),
)
}
}.recoverIfCancelled()
}
} }

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 MusicCarouselShelfRenderer( data class MusicCarouselShelfRenderer(
val header: Header, val header: Header,
@ -12,7 +10,8 @@ data class MusicCarouselShelfRenderer(
@Serializable @Serializable
data class Content( data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
) )
@Serializable @Serializable

View file

@ -93,7 +93,7 @@ data class NavigationEndpoint(
@Serializable @Serializable
data class Browse( data class Browse(
val params: String?, val params: String?,
val browseId: String, val browseId: String?,
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?,
) : Endpoint() { ) : Endpoint() {
val type: String? val type: String?