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

View file

@ -27,7 +27,7 @@ fun <T>ChipGroup(
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier modifier = Modifier
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState().also { })
.then(modifier) .then(modifier)
) { ) {
items.forEach { chipItem -> 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.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState 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.rememberArtistRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.vimusic.utils.*
@ -203,7 +203,7 @@ fun BaseMediaItemMenu(
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val albumRoute = rememberAlbumRoute() val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute() val artistRoute = rememberArtistRoute()
MediaItemMenu( MediaItemMenu(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -89,14 +89,14 @@ fun SearchResultScreen(
} }
} }
val albumRoute = rememberAlbumRoute() val playlistOrAlbumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute() val artistRoute = rememberArtistRoute()
RouteHandler( RouteHandler(
listenToGlobalEmitter = true listenToGlobalEmitter = true
) { ) {
albumRoute { browseId -> playlistOrAlbumRoute { browseId ->
AlbumScreen( PlaylistOrAlbumScreen(
browseId = browseId ?: "browseId cannot be null" browseId = browseId ?: "browseId cannot be null"
) )
} }
@ -176,6 +176,14 @@ fun SearchResultScreen(
text = "Videos", text = "Videos",
value = YouTube.Item.Video.Filter.value 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, value = preferences.searchFilter,
selectedBackgroundColor = colorPalette.primaryContainer, selectedBackgroundColor = colorPalette.primaryContainer,
@ -198,8 +206,9 @@ fun SearchResultScreen(
thumbnailSizePx = thumbnailSizePx, thumbnailSizePx = thumbnailSizePx,
onClick = { onClick = {
when (item) { 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.Artist -> artistRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Playlist -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Song -> { is YouTube.Item.Song -> {
player?.mediaController?.forcePlay(item.asMediaItem) player?.mediaController?.forcePlay(item.asMediaItem)
item.info.endpoint?.let { item.info.endpoint?.let {
@ -377,6 +386,18 @@ fun SmallItem(
onClick = onClick, onClick = onClick,
modifier = modifier 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 @Composable
fun SmallAlbumItem( fun SmallAlbumItem(
album: YouTube.Item.Album, album: YouTube.Item.Album,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -173,7 +173,6 @@ object YouTube {
} }
} }
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>>,
@ -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> { interface FromMusicShelfRendererContent<out T : Item> {
fun from(content: MusicShelfRenderer.Content): T fun from(content: MusicShelfRenderer.Content): T
} }
@ -313,7 +358,9 @@ object YouTube {
Item.Album.Filter.value -> Item.Album.Companion::from Item.Album.Filter.value -> Item.Album.Companion::from
Item.Artist.Filter.value -> Item.Artist.Companion::from Item.Artist.Filter.value -> Item.Artist.Companion::from
Item.Video.Filter.value -> Item.Video.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(), ) ?: emptyList(),
continuation = musicShelfRenderer continuation = musicShelfRenderer
@ -380,9 +427,10 @@ object YouTube {
name = renderer.title.text, name = renderer.title.text,
endpoint = renderer.navigationEndpoint.watchEndpoint endpoint = renderer.navigationEndpoint.watchEndpoint
), ),
authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)?.map { run -> authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)
Info.from(run) ?.map { run ->
} ?: emptyList(), Info.from(run)
} ?: emptyList(),
album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0) album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0)
?.let { run -> ?.let { run ->
Info.from(run) Info.from(run)
@ -536,75 +584,109 @@ object YouTube {
}.bodyCatching() }.bodyCatching()
} }
data class Album( open class PlaylistOrAlbum(
val title: String, 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(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val durationText: String?, 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 -> return browse(browseId).map { body ->
Album( PlaylistOrAlbum(
title = body title = body
.header!! .header
.musicDetailHeaderRenderer!! ?.musicDetailHeaderRenderer
.title ?.title
.text, ?.text,
thumbnail = body thumbnail = body
.header .header
.musicDetailHeaderRenderer!! ?.musicDetailHeaderRenderer
.thumbnail ?.thumbnail
.musicThumbnailRenderer ?.musicThumbnailRenderer
.thumbnail ?.thumbnail
.thumbnails.first(), ?.thumbnails
?.firstOrNull(),
authors = body authors = body
.header .header
.musicDetailHeaderRenderer ?.musicDetailHeaderRenderer
.subtitle.splitBySeparator().getOrNull(1)?.map { run -> ?.subtitle
Info.from(run) ?.splitBySeparator()
} ?: emptyList(), ?.getOrNull(1)
?.map { Info.from(it) },
year = body year = body
.header .header
.musicDetailHeaderRenderer ?.musicDetailHeaderRenderer
.subtitle.splitBySeparator().getOrNull(2)?.first()!!.text, ?.subtitle
?.splitBySeparator()
?.getOrNull(2)
?.firstOrNull()
?.text,
items = body items = body
.contents .contents
.singleColumnBrowseResultsRenderer!! .singleColumnBrowseResultsRenderer
.tabs ?.tabs
.first() ?.firstOrNull()
.tabRenderer ?.tabRenderer
.content!! ?.content
.sectionListRenderer ?.sectionListRenderer
.contents ?.contents
.first() ?.firstOrNull()
.musicShelfRenderer!! ?.musicShelfRenderer
.contents ?.contents
.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
.mapNotNull { renderer -> ?.mapNotNull { renderer ->
AlbumItem( PlaylistOrAlbum.Item(
info = Info.from( info = renderer
renderer.flexColumns.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull( .flexColumns
0 .getOrNull(0)
) ?: return@mapNotNull null ?.musicResponsiveListItemFlexColumnRenderer
), ?.text
authors = renderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.map { run -> ?.runs
Info.from<NavigationEndpoint.Endpoint.Browse>(run) ?.getOrNull(0)
}?.takeIf { it.isNotEmpty() }, ?.let { Info.from(it) } ?: return@mapNotNull null,
durationText = renderer.fixedColumns?.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull( authors = renderer
0 .flexColumns
)?.text .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( data class Content(
@JsonNames("musicImmersiveCarouselShelfRenderer") @JsonNames("musicImmersiveCarouselShelfRenderer")
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?, val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
@JsonNames("musicPlaylistShelfRenderer")
val musicShelfRenderer: MusicShelfRenderer?, val musicShelfRenderer: MusicShelfRenderer?,
val gridRenderer: GridRenderer?, val gridRenderer: GridRenderer?,
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?,