Add the ability to open YouTube playlist links

This commit is contained in:
vfsfitvnm 2022-06-06 20:27:23 +02:00
parent 756eb48176
commit c3b2435623
7 changed files with 235 additions and 146 deletions

View file

@ -35,7 +35,13 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="https"
android:host="music.youtube.com"
android:pathPrefix="/playlist" />
<data android:scheme="https"
android:host="www.youtube.com"
android:pathPrefix="/playlist" />
<data android:scheme="https"
android:host="music.youtube.com"

View file

@ -60,8 +60,6 @@ class MainActivity : ComponentActivity() {
val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java))
mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
val intentVideoId = intent?.data?.getQueryParameter("v")
setContent {
val preferences by rememberPreferences(dataStore)
val systemUiController = rememberSystemUiController()
@ -131,7 +129,7 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.background(LocalColorPalette.current.background)
) {
HomeScreen(intentVideoId = intentVideoId)
HomeScreen(intentUri = intent?.data)
BottomSheetMenu(
state = LocalMenuState.current,

View file

@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -50,9 +51,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun HomeScreen(intentVideoId: String?) {
fun HomeScreen(
intentUri: Uri?
) {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
@ -60,7 +64,7 @@ fun HomeScreen(intentVideoId: String?) {
val lazyListState = rememberLazyListState()
val intentVideoRoute = rememberIntentVideoRoute(intentVideoId)
val intentUriRoute = rememberIntentUriRoute(intentUri)
val settingsRoute = rememberSettingsRoute()
val playlistRoute = rememberLocalPlaylistRoute()
val searchRoute = rememberSearchRoute()
@ -68,7 +72,7 @@ fun HomeScreen(intentVideoId: String?) {
val albumRoute = rememberPlaylistOrAlbumRoute()
val artistRoute = rememberArtistRoute()
val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute })
val (route, onRouteChanged) = rememberRoute(intentUri?.let { intentUriRoute })
val playlistPreviews by remember {
Database.playlistPreviews()
@ -97,9 +101,9 @@ fun HomeScreen(intentVideoId: String?) {
onRouteChanged = onRouteChanged,
listenToGlobalEmitter = true
) {
intentVideoRoute { videoId ->
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 ->

View file

@ -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<List<YouTube.Item.Song>>>(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 -> {}
}
}
}
}
}

View file

@ -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<Outcome<MediaItem>>(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)
}
)
}
}
}
}
}

View file

@ -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<String?> {
val videoId = rememberSaveable {
mutableStateOf(intentVideoId)
fun rememberIntentUriRoute(intentUri: Uri?): Route1<Uri?> {
val uri = rememberSaveable {
mutableStateOf(intentUri)
}
return remember {
Route1("rememberIntentVideoRoute", videoId)
Route1("rememberIntentUriRoute", uri)
}
}

View file

@ -63,7 +63,8 @@ object YouTube {
@Serializable
data class GetQueueBody(
val context: Context,
val videoIds: List<String>
val videoIds: List<String>?,
val playlistId: String?,
)
@Serializable
@ -407,41 +408,65 @@ object YouTube {
}.bodyCatching()
}
suspend fun getQueue(videoId: String): Outcome<Item.Song?> {
private suspend fun getQueue(body: GetQueueBody): Outcome<List<Item.Song>?> {
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<GetQueueResponse>()
.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<Item.Song?> {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = listOf(videoId),
playlistId = null
)
).map { it?.firstOrNull() }
}
suspend fun queue(playlistId: String): Outcome<List<Item.Song>?> {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = null,
playlistId = playlistId
)
)
}
suspend fun next(
videoId: String,
playlistId: String?,