Support YouTube playlists
This commit is contained in:
parent
6f3c5467ec
commit
4d5af502cc
16 changed files with 352 additions and 146 deletions
|
@ -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
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?,
|
||||||
|
|
Loading…
Reference in a new issue