Support YouTube playlists

This commit is contained in:
vfsfitvnm 2022-06-06 18:08:52 +02:00
parent 6f3c5467ec
commit 4d5af502cc
16 changed files with 352 additions and 146 deletions

View file

@ -20,7 +20,7 @@
- Play any non-age-restricted song/video from YouTube Music
- Background playback
- Cache audio chunks for offline playback
- Search for songs, albums, artists and videos
- Search for songs, albums, artists videos and playlists
- Display songs lyrics
- Local playlist management
- Reorder songs in playlist or queue
@ -29,7 +29,6 @@
## TODO
- **Improve UI/UX** (help needed)
- Support YouTube playlists (and other stuff to improve features parity)
- Download songs (not sure about this)
- Play local songs (not sure about this, too)
- Translation

View file

@ -27,7 +27,7 @@ fun <T>ChipGroup(
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState().also { })
.then(modifier)
) {
items.forEach { chipItem ->

View file

@ -24,7 +24,7 @@ import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.screens.rememberAlbumRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberPlaylistOrAlbumRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
import it.vfsfitvnm.vimusic.utils.*
@ -203,7 +203,7 @@ fun BaseMediaItemMenu(
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
MediaItemMenu(

View file

@ -52,12 +52,12 @@ fun ArtistScreen(
}
}
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -65,7 +65,7 @@ fun HomeScreen(intentVideoId: String?) {
val playlistRoute = rememberLocalPlaylistRoute()
val searchRoute = rememberSearchRoute()
val searchResultRoute = rememberSearchResultRoute()
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute })
@ -136,7 +136,7 @@ fun HomeScreen(intentVideoId: String?) {
}
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -34,12 +34,12 @@ import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun IntentVideoScreen(videoId: String) {
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -44,27 +44,27 @@ import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun AlbumScreen(
fun PlaylistOrAlbumScreen(
browseId: String,
) {
val scrollState = rememberScrollState()
var album by remember {
mutableStateOf<Outcome<YouTube.Album>>(Outcome.Loading)
var playlistOrAlbum by remember {
mutableStateOf<Outcome<YouTube.PlaylistOrAlbum>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
album = withContext(Dispatchers.IO) {
YouTube.album(browseId)
playlistOrAlbum = withContext(Dispatchers.IO) {
YouTube.playlistOrAlbum(browseId)
}
}
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
@ -88,6 +88,12 @@ fun AlbumScreen(
}
}
val (songThumbnailSizeDp, songThumbnailSizePx) = remember {
density.run {
54.dp to 54.dp.roundToPx()
}
}
val coroutineScope = rememberCoroutineScope()
Column(
@ -128,10 +134,16 @@ fun AlbumScreen(
enabled = player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
album.valueOrNull?.let { album ->
player?.mediaController?.enqueue(album.items.mapNotNull { song ->
playlistOrAlbum.valueOrNull?.let { album ->
album.items
?.mapNotNull { song ->
song.toMediaItem(browseId, album)
})
}
?.let { mediaItems ->
player?.mediaController?.enqueue(
mediaItems
)
}
}
}
)
@ -142,15 +154,20 @@ fun AlbumScreen(
onClick = {
menuState.hide()
album.valueOrNull?.let { album ->
playlistOrAlbum.valueOrNull?.let { album ->
coroutineScope.launch(Dispatchers.IO) {
Database.internal.runInTransaction {
val playlistId = Database.insert(Playlist(name = album.title))
val playlistId =
Database.insert(Playlist(name = album.title ?: "Unknown"))
album.items.forEachIndexed { index, song ->
song.toMediaItem(browseId, album)?.let { mediaItem ->
album.items?.forEachIndexed { index, song ->
song
.toMediaItem(browseId, album)
?.let { mediaItem ->
if (Database.song(mediaItem.mediaId) == null) {
Database.insert(mediaItem)
Database.insert(
mediaItem
)
}
Database.insert(
@ -176,12 +193,12 @@ fun AlbumScreen(
}
OutcomeItem(
outcome = album,
outcome = playlistOrAlbum,
onRetry = onLoad,
onLoading = {
Loading()
}
) { album ->
) { playlistOrAlbum ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
@ -191,7 +208,7 @@ fun AlbumScreen(
.padding(bottom = 16.dp)
) {
AsyncImage(
model = album.thumbnail.size(thumbnailSizePx),
model = playlistOrAlbum.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
@ -206,12 +223,19 @@ fun AlbumScreen(
) {
Column {
BasicText(
text = album.title,
text = playlistOrAlbum.title ?: "Unknown",
style = typography.m.semiBold
)
BasicText(
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
text = buildString {
val authors = playlistOrAlbum.authors?.joinToString("") { it.name }
append(authors)
if (authors?.isNotEmpty() == true && playlistOrAlbum.year != null) {
append("")
}
append(playlistOrAlbum.year)
},
style = typography.xs.secondary.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -231,13 +255,19 @@ fun AlbumScreen(
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
album.items.shuffled().mapNotNull { song ->
song.toMediaItem(browseId, album)
})
playlistOrAlbum.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
player?.mediaController?.forcePlayFromBeginning(mediaItems)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
@ -249,12 +279,18 @@ fun AlbumScreen(
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(album.items.mapNotNull { song ->
song.toMediaItem(browseId, album)
})
playlistOrAlbum.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
player?.mediaController?.forcePlayFromBeginning(mediaItems)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
@ -262,18 +298,22 @@ fun AlbumScreen(
}
}
album.items.forEachIndexed { index, song ->
playlistOrAlbum.items?.forEachIndexed { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors ?: album.authors).joinToString("") { it.name },
authors = (song.authors ?: playlistOrAlbum.authors)?.joinToString("") { it.name },
durationText = song.durationText,
onClick = {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayAtIndex(album.items.mapNotNull { song ->
song.toMediaItem(browseId, album)
}, index)
playlistOrAlbum.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
player?.mediaController?.forcePlayAtIndex(mediaItems, index)
}
},
startContent = {
if (song.thumbnail == null) {
BasicText(
text = "${index + 1}",
style = typography.xs.secondary.bold.center,
@ -282,10 +322,21 @@ fun AlbumScreen(
modifier = Modifier
.width(36.dp)
)
} else {
AsyncImage(
model = song.thumbnail!!.size(songThumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(songThumbnailSizeDp)
)
}
},
menuContent = {
NonQueuedMediaItemMenu(
mediaItem = song.toMediaItem(browseId, album) ?: return@SongItem,
mediaItem = song.toMediaItem(browseId, playlistOrAlbum)
?: return@SongItem,
onDismiss = menuState::hide,
)
}

View file

@ -53,12 +53,12 @@ fun LocalPlaylistScreen(
val lazyListState = rememberLazyListState()
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -89,14 +89,14 @@ fun SearchResultScreen(
}
}
val albumRoute = rememberAlbumRoute()
val playlistOrAlbumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(
listenToGlobalEmitter = true
) {
albumRoute { browseId ->
AlbumScreen(
playlistOrAlbumRoute { browseId ->
PlaylistOrAlbumScreen(
browseId = browseId ?: "browseId cannot be null"
)
}
@ -176,6 +176,14 @@ fun SearchResultScreen(
text = "Videos",
value = YouTube.Item.Video.Filter.value
),
ChipItem(
text = "Playlists",
value = YouTube.Item.CommunityPlaylist.Filter.value
),
ChipItem(
text = "Featured playlists",
value = YouTube.Item.FeaturedPlaylist.Filter.value
),
),
value = preferences.searchFilter,
selectedBackgroundColor = colorPalette.primaryContainer,
@ -198,8 +206,9 @@ fun SearchResultScreen(
thumbnailSizePx = thumbnailSizePx,
onClick = {
when (item) {
is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Album -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Playlist -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Song -> {
player?.mediaController?.forcePlay(item.asMediaItem)
item.info.endpoint?.let {
@ -377,6 +386,18 @@ fun SmallItem(
onClick = onClick,
modifier = modifier
)
is YouTube.Item.Playlist -> SmallPlaylistItem(
playlist = item,
thumbnailSizeDp = thumbnailSizeDp,
thumbnailSizePx = thumbnailSizePx,
modifier = modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
}
@ -422,6 +443,56 @@ fun SmallVideoItem(
)
}
@ExperimentalAnimationApi
@Composable
fun SmallPlaylistItem(
playlist: YouTube.Item.Playlist,
thumbnailSizeDp: Dp,
thumbnailSizePx: Int,
modifier: Modifier = Modifier
) {
val typography = LocalTypography.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
) {
AsyncImage(
model = playlist.thumbnail.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
)
Column(
modifier = Modifier
.weight(1f)
) {
BasicText(
text = playlist.info.name,
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = buildString {
append(playlist.channel?.name)
if (playlist.channel?.name?.isEmpty() == false && playlist.songCount != null) {
append("")
}
append("${playlist.songCount} songs")
},
style = typography.xs,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun SmallAlbumItem(
album: YouTube.Item.Album,

View file

@ -85,12 +85,12 @@ fun SearchScreen(
}
}.collectAsState(initial = null, context = Dispatchers.IO)
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -26,14 +26,14 @@ import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi
@Composable
fun SettingsScreen() {
val albumRoute = rememberAlbumRoute()
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
val scrollState = rememberScrollState()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
PlaylistOrAlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}

View file

@ -18,12 +18,12 @@ fun rememberIntentVideoRoute(intentVideoId: String?): Route1<String?> {
}
@Composable
fun rememberAlbumRoute(): Route1<String?> {
fun rememberPlaylistOrAlbumRoute(): Route1<String?> {
val browseId = rememberSaveable {
mutableStateOf<String?>(null)
}
return remember {
Route1("AlbumRoute", browseId)
Route1("PlaylistOrAlbumRoute", browseId)
}
}

View file

@ -134,7 +134,7 @@ fun SongItem(
@Composable
fun SongItem(
title: String,
authors: String,
authors: String?,
durationText: String?,
onClick: () -> Unit,
startContent: @Composable () -> Unit,
@ -175,7 +175,7 @@ fun SongItem(
BasicText(
text = buildString {
append(authors)
if (authors.isNotEmpty() && durationText != null) {
if (authors?.isNotEmpty() == true && durationText != null) {
append("")
}
append(durationText)

View file

@ -144,25 +144,27 @@ val SongWithInfo.asMediaItem: MediaItem
.setMediaId(song.id)
.build()
fun YouTube.AlbumItem.toMediaItem(
fun YouTube.PlaylistOrAlbum.Item.toMediaItem(
albumId: String,
album: YouTube.Album
playlistOrAlbum: YouTube.PlaylistOrAlbum
): MediaItem? {
val isFromAlbum = thumbnail == null
return MediaItem.Builder()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist((authors ?: album.authors).joinToString("") { it.name })
.setAlbumTitle(album.title)
.setArtworkUri(album.thumbnail.url.toUri())
.setArtist((authors ?: playlistOrAlbum.authors)?.joinToString("") { it.name })
.setAlbumTitle(if (isFromAlbum) playlistOrAlbum.title else album?.name)
.setArtworkUri(if (isFromAlbum) playlistOrAlbum.thumbnail?.url?.toUri() else thumbnail?.url?.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint?.videoId,
"playlistId" to info.endpoint?.playlistId,
"albumId" to albumId,
"albumId" to (if (isFromAlbum) albumId else album?.endpoint?.browseId),
"durationText" to durationText,
"artistNames" to (authors ?: album.authors).map { it.name },
"artistIds" to (authors ?: album.authors).map { it.endpoint?.browseId }
"artistNames" to (authors ?: playlistOrAlbum.authors)?.map { it.name },
"artistIds" to (authors ?: playlistOrAlbum.authors)?.map { it.endpoint?.browseId }
)
)
.build()

View file

@ -173,7 +173,6 @@ object YouTube {
}
}
data class Video(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
@ -253,6 +252,52 @@ object YouTube {
}
}
data class Playlist(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
val songCount: Int?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail,
) : Item() {
companion object : FromMusicShelfRendererContent<Playlist> {
override fun from(content: MusicShelfRenderer.Content): Playlist {
val (mainRuns, otherRuns) = content.runs
return Playlist(
info = Info(
name = mainRuns
.firstOrNull()
?.text ?: "?",
endpoint = content
.musicResponsiveListItemRenderer
.navigationEndpoint
?.browseEndpoint
),
channel = otherRuns
.firstOrNull()
?.firstOrNull()?.let {
Info.from(it)
},
songCount = otherRuns
.lastOrNull()
?.firstOrNull()
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = content.thumbnail
)
}
}
}
object CommunityPlaylist {
val Filter = Filter("EgeKAQQoAEABagoQAxAEEAoQCRAF")
}
object FeaturedPlaylist {
val Filter = Filter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
}
interface FromMusicShelfRendererContent<out T : Item> {
fun from(content: MusicShelfRenderer.Content): T
}
@ -313,7 +358,9 @@ object YouTube {
Item.Album.Filter.value -> Item.Album.Companion::from
Item.Artist.Filter.value -> Item.Artist.Companion::from
Item.Video.Filter.value -> Item.Video.Companion::from
else -> error("Unknow filter: $filter")
Item.CommunityPlaylist.Filter.value -> Item.Playlist.Companion::from
Item.FeaturedPlaylist.Filter.value -> Item.Playlist.Companion::from
else -> error("Unknown filter: $filter")
}
) ?: emptyList(),
continuation = musicShelfRenderer
@ -380,7 +427,8 @@ object YouTube {
name = renderer.title.text,
endpoint = renderer.navigationEndpoint.watchEndpoint
),
authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)?.map { run ->
authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)
?.map { run ->
Info.from(run)
} ?: emptyList(),
album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0)
@ -536,75 +584,109 @@ object YouTube {
}.bodyCatching()
}
data class Album(
val title: String,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
val year: String,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail,
val items: List<AlbumItem>
)
data class AlbumItem(
open class PlaylistOrAlbum(
val title: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
val items: List<Item>?
) {
open class Item(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val durationText: String?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
)
}
suspend fun album(browseId: String): Outcome<Album> {
suspend fun playlistOrAlbum(browseId: String): Outcome<PlaylistOrAlbum> {
return browse(browseId).map { body ->
Album(
PlaylistOrAlbum(
title = body
.header!!
.musicDetailHeaderRenderer!!
.title
.text,
.header
?.musicDetailHeaderRenderer
?.title
?.text,
thumbnail = body
.header
.musicDetailHeaderRenderer!!
.thumbnail
.musicThumbnailRenderer
.thumbnail
.thumbnails.first(),
?.musicDetailHeaderRenderer
?.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull(),
authors = body
.header
.musicDetailHeaderRenderer
.subtitle.splitBySeparator().getOrNull(1)?.map { run ->
Info.from(run)
} ?: emptyList(),
?.musicDetailHeaderRenderer
?.subtitle
?.splitBySeparator()
?.getOrNull(1)
?.map { Info.from(it) },
year = body
.header
.musicDetailHeaderRenderer
.subtitle.splitBySeparator().getOrNull(2)?.first()!!.text,
?.musicDetailHeaderRenderer
?.subtitle
?.splitBySeparator()
?.getOrNull(2)
?.firstOrNull()
?.text,
items = body
.contents
.singleColumnBrowseResultsRenderer!!
.tabs
.first()
.tabRenderer
.content!!
.sectionListRenderer
.contents
.first()
.musicShelfRenderer!!
.contents
.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
.mapNotNull { renderer ->
AlbumItem(
info = Info.from(
renderer.flexColumns.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull(
0
) ?: return@mapNotNull null
),
authors = renderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.map { run ->
Info.from<NavigationEndpoint.Endpoint.Browse>(run)
}?.takeIf { it.isNotEmpty() },
durationText = renderer.fixedColumns?.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull(
0
)?.text
.singleColumnBrowseResultsRenderer
?.tabs
?.firstOrNull()
?.tabRenderer
?.content
?.sectionListRenderer
?.contents
?.firstOrNull()
?.musicShelfRenderer
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull { renderer ->
PlaylistOrAlbum.Item(
info = renderer
.flexColumns
.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.let { Info.from(it) } ?: return@mapNotNull null,
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) }
?.takeIf { it.isNotEmpty() },
durationText = renderer
.fixedColumns
?.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.text,
album = renderer
.flexColumns
.getOrNull(2)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.firstOrNull()
?.let { Info.from(it) },
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
)
}.filter { item ->
item.info.endpoint != null
}
?.filter { it.info.endpoint != null }
)
}
}

View file

@ -34,6 +34,7 @@ data class SectionListRenderer(
data class Content(
@JsonNames("musicImmersiveCarouselShelfRenderer")
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
@JsonNames("musicPlaylistShelfRenderer")
val musicShelfRenderer: MusicShelfRenderer?,
val gridRenderer: GridRenderer?,
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?,