Cache artist information

This commit is contained in:
vfsfitvnm 2022-06-29 20:15:43 +02:00
parent 309a1a4237
commit 1fb50169ff
5 changed files with 166 additions and 59 deletions

View file

@ -4,13 +4,18 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.* import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -28,8 +33,9 @@ import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.DetailedSong import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.TopAppBar import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
@ -37,11 +43,13 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ -51,16 +59,6 @@ fun ArtistScreen(
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
var artist by remember {
mutableStateOf<Outcome<YouTube.Artist>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
artist = withContext(Dispatchers.IO) {
YouTube.artist(browseId)
}
}
val albumRoute = rememberPlaylistOrAlbumRoute() val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute() val artistRoute = rememberArtistRoute()
@ -84,6 +82,26 @@ fun ArtistScreen(
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current val typography = LocalTypography.current
val artistResult by remember(browseId) {
Database.artist(browseId).map { artist ->
artist?.takeIf {
artist.shufflePlaylistId != null
}?.let(Result.Companion::success) ?: YouTube.artist(browseId)
.map { youtubeArtist ->
Artist(
id = browseId,
name = youtubeArtist.name,
thumbnailUrl = youtubeArtist.thumbnail?.url,
info = youtubeArtist.description,
shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId,
shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId,
radioVideoId = youtubeArtist.radioEndpoint?.videoId,
radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId,
).also(Database::update)
}
}.distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val (thumbnailSizeDp, thumbnailSizePx) = remember { val (thumbnailSizeDp, thumbnailSizePx) = remember {
density.run { density.run {
192.dp to 192.dp.roundToPx() 192.dp to 192.dp.roundToPx()
@ -127,18 +145,19 @@ fun ArtistScreen(
} }
item { item {
OutcomeItem( artistResult?.getOrNull()?.let { artist ->
outcome = artist,
onRetry = onLoad,
onLoading = {
Loading()
}
) { artist ->
AsyncImage( AsyncImage(
model = artist.thumbnail?.size(thumbnailSizePx), model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.clickable {
query {
runBlocking {
Database.artist(browseId).first()?.copy(shufflePlaylistId = null)?.let(Database::update)
}
}
}
.size(thumbnailSizeDp) .size(thumbnailSizeDp)
) )
@ -160,7 +179,12 @@ fun ArtistScreen(
colorFilter = ColorFilter.tint(colorPalette.text), colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier modifier = Modifier
.clickable { .clickable {
binder?.playRadio(artist.shuffleEndpoint) binder?.playRadio(
NavigationEndpoint.Endpoint.Watch(
videoId = artist.shuffleVideoId,
playlistId = artist.shufflePlaylistId
)
)
} }
.shadow(elevation = 2.dp, shape = CircleShape) .shadow(elevation = 2.dp, shape = CircleShape)
.background( .background(
@ -177,7 +201,12 @@ fun ArtistScreen(
colorFilter = ColorFilter.tint(colorPalette.text), colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier modifier = Modifier
.clickable { .clickable {
binder?.playRadio(artist.radioEndpoint) binder?.playRadio(
NavigationEndpoint.Endpoint.Watch(
videoId = artist.radioVideoId,
playlistId = artist.radioPlaylistId
)
)
} }
.shadow(elevation = 2.dp, shape = CircleShape) .shadow(elevation = 2.dp, shape = CircleShape)
.background( .background(
@ -188,9 +217,18 @@ fun ArtistScreen(
.size(20.dp) .size(20.dp)
) )
} }
} ?: artistResult?.exceptionOrNull()?.let { throwable ->
LoadingOrError(
} errorMessage = throwable.javaClass.canonicalName,
onRetry = {
query {
runBlocking {
Database.artist(browseId).first()?.let(Database::update)
}
}
}
)
} ?: LoadingOrError()
} }
item { item {
@ -219,7 +257,11 @@ fun ArtistScreen(
modifier = Modifier modifier = Modifier
.clickable(enabled = songs.isNotEmpty()) { .clickable(enabled = songs.isNotEmpty()) {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(songs.shuffled().map(DetailedSong::asMediaItem)) binder?.player?.forcePlayFromBeginning(
songs
.shuffled()
.map(DetailedSong::asMediaItem)
)
} }
.padding(horizontal = 8.dp, vertical = 8.dp) .padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp) .size(20.dp)
@ -237,7 +279,10 @@ fun ArtistScreen(
thumbnailSize = songThumbnailSizePx, thumbnailSize = songThumbnailSizePx,
onClick = { onClick = {
binder?.stopRadio() binder?.stopRadio()
binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index) binder?.player?.forcePlayAtIndex(
songs.map(DetailedSong::asMediaItem),
index
)
}, },
menuContent = { menuContent = {
InHistoryMediaItemMenu(song = song) InHistoryMediaItemMenu(song = song)
@ -245,7 +290,7 @@ fun ArtistScreen(
) )
} }
artist.valueOrNull?.description?.let { description -> artistResult?.getOrNull()?.info?.let { description ->
item { item {
Column( Column(
modifier = Modifier modifier = Modifier
@ -272,33 +317,78 @@ fun ArtistScreen(
} }
@Composable @Composable
private fun Loading() { private fun LoadingOrError(
errorMessage: String? = null,
onRetry: (() -> Unit)? = null
) {
val typography = LocalTypography.current
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
Column( Box {
horizontalAlignment = Alignment.CenterHorizontally, Column(
modifier = Modifier horizontalAlignment = Alignment.CenterHorizontally,
.shimmer()
) {
Spacer(
modifier = Modifier modifier = Modifier
.background(color = colorPalette.darkGray, shape = CircleShape) .alpha(if (errorMessage == null) 1f else 0f)
.size(192.dp) .shimmer()
) ) {
Spacer(
modifier = Modifier
.background(color = colorPalette.darkGray, shape = CircleShape)
.size(192.dp)
)
TextPlaceholder(
modifier = Modifier
.alpha(0.9f)
.padding(vertical = 8.dp, horizontal = 16.dp)
)
repeat(3) {
TextPlaceholder( TextPlaceholder(
modifier = Modifier modifier = Modifier
.alpha(0.8f) .alpha(0.9f)
.padding(horizontal = 16.dp) .padding(vertical = 8.dp, horizontal = 16.dp)
) )
repeat(3) {
TextPlaceholder(
modifier = Modifier
.alpha(0.8f)
.padding(horizontal = 16.dp)
)
}
}
errorMessage?.let {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 16.dp, vertical = 16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true),
enabled = onRetry != null,
onClick = onRetry ?: {}
)
.background(colorPalette.lightBackground)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Image(
painter = painterResource(R.drawable.alert_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.red),
modifier = Modifier
.padding(bottom = 16.dp)
.size(24.dp)
)
BasicText(
text = onRetry?.let { "Tap to retry" } ?: "Error",
style = typography.xxs.semiBold,
modifier = Modifier
.padding(horizontal = 16.dp)
)
BasicText(
text = "An error has occurred:\n$errorMessage",
style = typography.xxs.secondary,
modifier = Modifier
.padding(horizontal = 16.dp)
)
}
} }
} }
} }

View file

@ -24,7 +24,7 @@ data class YouTubeRadio(
nextContinuation = withContext(Dispatchers.IO) { nextContinuation = withContext(Dispatchers.IO) {
YouTube.next( YouTube.next(
videoId = videoId ?: error("This should not happen"), videoId = videoId,
playlistId = playlistId, playlistId = playlistId,
params = parameters, params = parameters,
playlistSetVideoId = playlistSetVideoId, playlistSetVideoId = playlistSetVideoId,

View file

@ -75,7 +75,7 @@ fun Database.insert(mediaItem: MediaItem): Song {
val YouTube.Item.Song.asMediaItem: MediaItem val YouTube.Item.Song.asMediaItem: MediaItem
get() = MediaItem.Builder() get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId) .setMediaId(info.endpoint!!.videoId!!)
.setUri(info.endpoint!!.videoId) .setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId) .setCustomCacheKey(info.endpoint!!.videoId)
.setMediaMetadata( .setMediaMetadata(
@ -99,7 +99,7 @@ val YouTube.Item.Song.asMediaItem: MediaItem
val YouTube.Item.Video.asMediaItem: MediaItem val YouTube.Item.Video.asMediaItem: MediaItem
get() = MediaItem.Builder() get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId) .setMediaId(info.endpoint!!.videoId!!)
.setUri(info.endpoint!!.videoId) .setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId) .setCustomCacheKey(info.endpoint!!.videoId)
.setMediaMetadata( .setMediaMetadata(

View file

@ -1,6 +1,7 @@
package it.vfsfitvnm.youtubemusic package it.vfsfitvnm.youtubemusic
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.client.plugins.compression.* import io.ktor.client.plugins.compression.*
@ -71,7 +72,7 @@ object YouTube {
data class NextBody( data class NextBody(
val context: Context, val context: Context,
val isAudioOnly: Boolean, val isAudioOnly: Boolean,
val videoId: String, val videoId: String?,
val playlistId: String?, val playlistId: String?,
val tunerSettingValue: String, val tunerSettingValue: String,
val index: Int?, val index: Int?,
@ -532,7 +533,7 @@ object YouTube {
} }
suspend fun next( suspend fun next(
videoId: String, videoId: String?,
playlistId: String?, playlistId: String?,
index: Int? = null, index: Int? = null,
params: String? = null, params: String? = null,
@ -688,6 +689,22 @@ object YouTube {
}.bodyCatching() }.bodyCatching()
} }
suspend fun browse2(browseId: String): Result<BrowseResponse> {
return runCatching {
client.post("/youtubei/v1/browse") {
contentType(ContentType.Application.Json)
setBody(
BrowseBody(
browseId = browseId,
context = Context.DefaultWeb
)
)
parameter("key", Key)
parameter("prettyPrint", false)
}.body()
}
}
open class PlaylistOrAlbum( open class PlaylistOrAlbum(
val title: String?, val title: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?, val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
@ -821,8 +838,8 @@ object YouTube {
val radioEndpoint: NavigationEndpoint.Endpoint.Watch? val radioEndpoint: NavigationEndpoint.Endpoint.Watch?
) )
suspend fun artist(browseId: String): Outcome<Artist> { suspend fun artist(browseId: String): Result<Artist> {
return browse(browseId).map { body -> return browse2(browseId).map { body ->
Artist( Artist(
name = body name = body
.header .header

View file

@ -62,7 +62,7 @@ data class NavigationEndpoint(
data class Watch( data class Watch(
val params: String? = null, val params: String? = null,
val playlistId: String? = null, val playlistId: String? = null,
val videoId: String, val videoId: String? = null,
val index: Int? = null, val index: Int? = null,
val playlistSetVideoId: String? = null, val playlistSetVideoId: String? = null,
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null,