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)