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

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

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