Cache artist information
This commit is contained in:
parent
309a1a4237
commit
1fb50169ff
5 changed files with 166 additions and 59 deletions
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue