diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt index eb7dcae..c782bac 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt @@ -4,13 +4,18 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape 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.Modifier import androidx.compose.ui.draw.alpha @@ -28,8 +33,9 @@ import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Artist 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.themed.InHistoryMediaItemMenu 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.views.SongItem import it.vfsfitvnm.vimusic.utils.* -import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking @ExperimentalAnimationApi @@ -51,16 +59,6 @@ fun ArtistScreen( ) { val lazyListState = rememberLazyListState() - var artist by remember { - mutableStateOf>(Outcome.Loading) - } - - val onLoad = relaunchableEffect(Unit) { - artist = withContext(Dispatchers.IO) { - YouTube.artist(browseId) - } - } - val albumRoute = rememberPlaylistOrAlbumRoute() val artistRoute = rememberArtistRoute() @@ -84,6 +82,26 @@ fun ArtistScreen( val colorPalette = LocalColorPalette.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 { density.run { 192.dp to 192.dp.roundToPx() @@ -127,18 +145,19 @@ fun ArtistScreen( } item { - OutcomeItem( - outcome = artist, - onRetry = onLoad, - onLoading = { - Loading() - } - ) { artist -> + artistResult?.getOrNull()?.let { artist -> AsyncImage( - model = artist.thumbnail?.size(thumbnailSizePx), + model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx), contentDescription = null, modifier = Modifier .clip(CircleShape) + .clickable { + query { + runBlocking { + Database.artist(browseId).first()?.copy(shufflePlaylistId = null)?.let(Database::update) + } + } + } .size(thumbnailSizeDp) ) @@ -160,7 +179,12 @@ fun ArtistScreen( colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .clickable { - binder?.playRadio(artist.shuffleEndpoint) + binder?.playRadio( + NavigationEndpoint.Endpoint.Watch( + videoId = artist.shuffleVideoId, + playlistId = artist.shufflePlaylistId + ) + ) } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -177,7 +201,12 @@ fun ArtistScreen( colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier .clickable { - binder?.playRadio(artist.radioEndpoint) + binder?.playRadio( + NavigationEndpoint.Endpoint.Watch( + videoId = artist.radioVideoId, + playlistId = artist.radioPlaylistId + ) + ) } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -188,9 +217,18 @@ fun ArtistScreen( .size(20.dp) ) } - - - } + } ?: artistResult?.exceptionOrNull()?.let { throwable -> + LoadingOrError( + errorMessage = throwable.javaClass.canonicalName, + onRetry = { + query { + runBlocking { + Database.artist(browseId).first()?.let(Database::update) + } + } + } + ) + } ?: LoadingOrError() } item { @@ -219,7 +257,11 @@ fun ArtistScreen( modifier = Modifier .clickable(enabled = songs.isNotEmpty()) { 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) .size(20.dp) @@ -237,7 +279,10 @@ fun ArtistScreen( thumbnailSize = songThumbnailSizePx, onClick = { binder?.stopRadio() - binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index) + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) }, menuContent = { InHistoryMediaItemMenu(song = song) @@ -245,7 +290,7 @@ fun ArtistScreen( ) } - artist.valueOrNull?.description?.let { description -> + artistResult?.getOrNull()?.info?.let { description -> item { Column( modifier = Modifier @@ -272,33 +317,78 @@ fun ArtistScreen( } @Composable -private fun Loading() { +private fun LoadingOrError( + errorMessage: String? = null, + onRetry: (() -> Unit)? = null +) { + val typography = LocalTypography.current val colorPalette = LocalColorPalette.current - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .shimmer() - ) { - Spacer( + Box { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .background(color = colorPalette.darkGray, shape = CircleShape) - .size(192.dp) - ) + .alpha(if (errorMessage == null) 1f else 0f) + .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( modifier = Modifier - .alpha(0.8f) - .padding(horizontal = 16.dp) + .alpha(0.9f) + .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) + ) + } } } } - diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt index c4eabe2..81ab121 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt @@ -24,7 +24,7 @@ data class YouTubeRadio( nextContinuation = withContext(Dispatchers.IO) { YouTube.next( - videoId = videoId ?: error("This should not happen"), + videoId = videoId, playlistId = playlistId, params = parameters, playlistSetVideoId = playlistSetVideoId, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt index 2a7efb7..3f6381f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt @@ -75,7 +75,7 @@ fun Database.insert(mediaItem: MediaItem): Song { val YouTube.Item.Song.asMediaItem: MediaItem get() = MediaItem.Builder() - .setMediaId(info.endpoint!!.videoId) + .setMediaId(info.endpoint!!.videoId!!) .setUri(info.endpoint!!.videoId) .setCustomCacheKey(info.endpoint!!.videoId) .setMediaMetadata( @@ -99,7 +99,7 @@ val YouTube.Item.Song.asMediaItem: MediaItem val YouTube.Item.Video.asMediaItem: MediaItem get() = MediaItem.Builder() - .setMediaId(info.endpoint!!.videoId) + .setMediaId(info.endpoint!!.videoId!!) .setUri(info.endpoint!!.videoId) .setCustomCacheKey(info.endpoint!!.videoId) .setMediaMetadata( diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index 9f4df2e..2ea0ff2 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -1,6 +1,7 @@ package it.vfsfitvnm.youtubemusic import io.ktor.client.* +import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.compression.* @@ -71,7 +72,7 @@ object YouTube { data class NextBody( val context: Context, val isAudioOnly: Boolean, - val videoId: String, + val videoId: String?, val playlistId: String?, val tunerSettingValue: String, val index: Int?, @@ -532,7 +533,7 @@ object YouTube { } suspend fun next( - videoId: String, + videoId: String?, playlistId: String?, index: Int? = null, params: String? = null, @@ -688,6 +689,22 @@ object YouTube { }.bodyCatching() } + suspend fun browse2(browseId: String): Result { + 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( val title: String?, val authors: List>?, @@ -821,8 +838,8 @@ object YouTube { val radioEndpoint: NavigationEndpoint.Endpoint.Watch? ) - suspend fun artist(browseId: String): Outcome { - return browse(browseId).map { body -> + suspend fun artist(browseId: String): Result { + return browse2(browseId).map { body -> Artist( name = body .header diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt index a1751dc..a20fd81 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/NavigationEndpoint.kt @@ -62,7 +62,7 @@ data class NavigationEndpoint( data class Watch( val params: String? = null, val playlistId: String? = null, - val videoId: String, + val videoId: String? = null, val index: Int? = null, val playlistSetVideoId: String? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null,