diff --git a/README.md b/README.md index 6bc4301..4c52f1f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt index c739794..63206a8 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt @@ -27,7 +27,7 @@ fun ChipGroup( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .horizontalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState().also { }) .then(modifier) ) { items.forEach { chipItem -> diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt index 3f687c9..e633155 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt @@ -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( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt index c998848..13a30e3 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt index 5afa5ad..a0a92d9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt index 75b37a4..3ec5214 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistOrAlbumScreen.kt similarity index 69% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistOrAlbumScreen.kt index dc2816a..649b081 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistOrAlbumScreen.kt @@ -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.Loading) + var playlistOrAlbum by remember { + mutableStateOf>(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 -> - song.toMediaItem(browseId, album) - }) + playlistOrAlbum.valueOrNull?.let { album -> + album.items + ?.mapNotNull { song -> + song.toMediaItem(browseId, album) + } + ?.let { mediaItems -> + player?.mediaController?.enqueue( + mediaItems + ) + } } } ) @@ -142,25 +154,30 @@ 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 -> - if (Database.song(mediaItem.mediaId) == null) { - Database.insert(mediaItem) - } + album.items?.forEachIndexed { index, song -> + song + .toMediaItem(browseId, album) + ?.let { mediaItem -> + if (Database.song(mediaItem.mediaId) == null) { + Database.insert( + mediaItem + ) + } - Database.insert( - SongInPlaylist( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index + Database.insert( + SongInPlaylist( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) ) - ) - } + } } } } @@ -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,30 +298,45 @@ 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 = { - BasicText( - text = "${index + 1}", - style = typography.xs.secondary.bold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(36.dp) - ) + if (song.thumbnail == null) { + BasicText( + text = "${index + 1}", + style = typography.xs.secondary.bold.center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + 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, ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt index 54bb2fc..cc3943a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt index 8ceacf7..2b2ee6c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt @@ -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, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt index 8b882c0..15ba1f3 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt index d8e8794..227d2df 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt @@ -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") ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt index 2030859..94ef451 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt @@ -18,12 +18,12 @@ fun rememberIntentVideoRoute(intentVideoId: String?): Route1 { } @Composable -fun rememberAlbumRoute(): Route1 { +fun rememberPlaylistOrAlbumRoute(): Route1 { val browseId = rememberSaveable { mutableStateOf(null) } return remember { - Route1("AlbumRoute", browseId) + Route1("PlaylistOrAlbumRoute", browseId) } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt index fb4b02f..1d0f0a9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt @@ -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) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt index d213886..45daa24 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt @@ -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() diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index ebf57df..2e51418 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -173,7 +173,6 @@ object YouTube { } } - data class Video( val info: Info, val authors: List>, @@ -253,6 +252,52 @@ object YouTube { } } + data class Playlist( + val info: Info, + val channel: Info?, + val songCount: Int?, + override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail, + ) : Item() { + companion object : FromMusicShelfRendererContent { + 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 { 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,9 +427,10 @@ object YouTube { name = renderer.title.text, endpoint = renderer.navigationEndpoint.watchEndpoint ), - authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)?.map { run -> - Info.from(run) - } ?: emptyList(), + authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0) + ?.map { run -> + Info.from(run) + } ?: emptyList(), album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0) ?.let { run -> Info.from(run) @@ -536,75 +584,109 @@ object YouTube { }.bodyCatching() } - data class Album( - val title: String, - val authors: List>, - val year: String, - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail, - val items: List - ) - - data class AlbumItem( - val info: Info, + open class PlaylistOrAlbum( + val title: String?, val authors: List>?, - val durationText: String?, - ) + val year: String?, + val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?, + val items: List? + ) { + open class Item( + val info: Info, + val authors: List>?, + val durationText: String?, + val album: Info?, + val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?, + ) + } - suspend fun album(browseId: String): Outcome { + suspend fun playlistOrAlbum(browseId: String): Outcome { 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(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(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 } ) } } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt index cdfaa31..90561ca 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/Tabs.kt @@ -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?,