diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b869af..4c63468 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,7 +35,13 @@ - + + + - IntentVideoScreen( - videoId = videoId ?: error("videoId must be not null") + intentUriRoute { uri -> + IntentUriScreen( + uri = uri ?: error("uri must be not null") ) } @@ -136,9 +140,7 @@ fun HomeScreen(intentVideoId: String?) { } albumRoute { browseId -> - PlaylistOrAlbumScreen( - browseId = browseId ?: error("browseId cannot be null") - ) + PlaylistOrAlbumScreen(browseId = browseId ?: error("browseId cannot be null")) } artistRoute { browseId -> diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt new file mode 100644 index 0000000..e9bc748 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt @@ -0,0 +1,161 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import android.net.Uri +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.ShimmerBounds +import com.valentinilk.shimmer.rememberShimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.Error +import it.vfsfitvnm.vimusic.ui.components.Message +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.toNullable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +fun IntentUriScreen(uri: Uri) { + val albumRoute = rememberPlaylistOrAlbumRoute() + val artistRoute = rememberArtistRoute() + + val lazyListState = rememberLazyListState() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + PlaylistOrAlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val colorPalette = LocalColorPalette.current + val density = LocalDensity.current + val player = LocalYoutubePlayer.current + + val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window) + + + var items by remember { + mutableStateOf>>(Outcome.Loading) + } + + val onLoad = relaunchableEffect(Unit) { + items = withContext(Dispatchers.IO) { + uri.getQueryParameter("list")?.let { playlistId -> + YouTube.queue(playlistId).toNullable()?.map { songList -> + songList + } + } ?: uri.getQueryParameter("v")?.let { videoId -> + YouTube.song(videoId).toNullable()?.map { listOf(it) } + } ?: Outcome.Error.Network + } + } + + LazyColumn( + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(bottom = 64.dp), + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + item { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + } + } + + when (val currentItems = items) { + is Outcome.Error -> item { + Error( + error = currentItems, + onRetry = onLoad, + modifier = Modifier + .padding(vertical = 16.dp) + ) + } + is Outcome.Recovered -> item { + Error( + error = currentItems.error, + onRetry = onLoad, + modifier = Modifier + .padding(vertical = 16.dp) + ) + } + is Outcome.Loading, is Outcome.Initial -> items(count = 5) { index -> + SmallSongItemShimmer( + shimmer = shimmer, + thumbnailSizeDp = 54.dp, + modifier = Modifier + .alpha(1f - index * 0.175f) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + is Outcome.Success -> { + if (currentItems.value.isEmpty()) { + item { + Message( + text = "No songs were found", + modifier = Modifier + ) + } + } else { + itemsIndexed(currentItems.value) { index, item -> + SmallSongItem( + song = item, + thumbnailSizePx = density.run { 54.dp.roundToPx() }, + onClick = { + YoutubePlayer.Radio.reset() + + player?.mediaController?.forcePlayAtIndex(currentItems.value.map(YouTube.Item.Song::asMediaItem), index) + pop() + } + ) + } + } + } + else -> {} + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt deleted file mode 100644 index 3ec5214..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt +++ /dev/null @@ -1,104 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import com.valentinilk.shimmer.ShimmerBounds -import com.valentinilk.shimmer.rememberShimmer -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.ui.components.OutcomeItem -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.youtubemusic.Outcome -import it.vfsfitvnm.youtubemusic.YouTube -import it.vfsfitvnm.youtubemusic.toNullable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun IntentVideoScreen(videoId: String) { - val albumRoute = rememberPlaylistOrAlbumRoute() - val artistRoute = rememberArtistRoute() - - RouteHandler(listenToGlobalEmitter = true) { - albumRoute { browseId -> - PlaylistOrAlbumScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - host { - val colorPalette = LocalColorPalette.current - val density = LocalDensity.current - val player = LocalYoutubePlayer.current - - val mediaItem by produceState>(initialValue = Outcome.Loading) { - value = withContext(Dispatchers.IO) { - Database.songWithInfo(videoId)?.let { songWithInfo -> - Outcome.Success(songWithInfo.asMediaItem) - } ?: YouTube.getQueue(videoId).toNullable() - ?.map(YouTube.Item.Song::asMediaItem) - ?: Outcome.Error.Network - } - } - - Column( - modifier = Modifier - .background(colorPalette.background) - .fillMaxSize() - ) { - OutcomeItem( - outcome = mediaItem, - onLoading = { - SmallSongItemShimmer( - shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View), - thumbnailSizeDp = 54.dp, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) - } - ) { mediaItem -> - SongItem( - mediaItem = mediaItem, - thumbnailSize = remember { - density.run { - 54.dp.roundToPx() - } - }, - onClick = { - player?.mediaController?.forcePlay(mediaItem) - pop() - }, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = mediaItem) - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt index 94ef451..06c4bd2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt @@ -1,5 +1,6 @@ package it.vfsfitvnm.vimusic.ui.screens +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -8,12 +9,12 @@ import it.vfsfitvnm.route.Route0 import it.vfsfitvnm.route.Route1 @Composable -fun rememberIntentVideoRoute(intentVideoId: String?): Route1 { - val videoId = rememberSaveable { - mutableStateOf(intentVideoId) +fun rememberIntentUriRoute(intentUri: Uri?): Route1 { + val uri = rememberSaveable { + mutableStateOf(intentUri) } return remember { - Route1("rememberIntentVideoRoute", videoId) + Route1("rememberIntentUriRoute", uri) } } 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 cf28889..3660974 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -63,7 +63,8 @@ object YouTube { @Serializable data class GetQueueBody( val context: Context, - val videoIds: List + val videoIds: List?, + val playlistId: String?, ) @Serializable @@ -407,41 +408,65 @@ object YouTube { }.bodyCatching() } - suspend fun getQueue(videoId: String): Outcome { + private suspend fun getQueue(body: GetQueueBody): Outcome?> { return client.postCatching("/youtubei/v1/music/get_queue") { contentType(ContentType.Application.Json) - setBody( - GetQueueBody( - context = Context.DefaultWeb, - videoIds = listOf(videoId) - ) - ) + setBody(body) parameter("key", Key) parameter("prettyPrint", false) } .bodyCatching() .map { body -> - body.queueDatas?.firstOrNull()?.content?.playlistPanelVideoRenderer?.let { renderer -> - Item.Song( - info = Info( - name = renderer.title.text, - endpoint = renderer.navigationEndpoint.watchEndpoint - ), - authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0) - ?.map { run -> - Info.from(run) - } ?: emptyList(), - album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0) - ?.let { run -> - Info.from(run) - }, - thumbnail = renderer.thumbnail.thumbnails[0], - durationText = renderer.lengthText.text - ) + body.queueDatas?.mapNotNull { queueData -> + queueData.content?.playlistPanelVideoRenderer?.let { renderer -> + Item.Song( + info = Info( + name = renderer + .title + .text, + endpoint = renderer + .navigationEndpoint + .watchEndpoint + ), + authors = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(0) + ?.map { Info.from(it) } ?: emptyList(), + album = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(1) + ?.get(0) + ?.let { Info.from(it) }, + thumbnail = renderer.thumbnail.thumbnails[0], + durationText = renderer.lengthText.text + ) + } } } } + suspend fun song(videoId: String): Outcome { + return getQueue( + GetQueueBody( + context = Context.DefaultWeb, + videoIds = listOf(videoId), + playlistId = null + ) + ).map { it?.firstOrNull() } + } + + suspend fun queue(playlistId: String): Outcome?> { + return getQueue( + GetQueueBody( + context = Context.DefaultWeb, + videoIds = null, + playlistId = playlistId + ) + ) + } + suspend fun next( videoId: String, playlistId: String?,