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.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <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" <data android:scheme="https"
android:host="music.youtube.com" android:host="music.youtube.com"

View file

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

View file

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

View file

@ -63,7 +63,8 @@ object YouTube {
@Serializable @Serializable
data class GetQueueBody( data class GetQueueBody(
val context: Context, val context: Context,
val videoIds: List<String> val videoIds: List<String>?,
val playlistId: String?,
) )
@Serializable @Serializable
@ -407,41 +408,65 @@ object YouTube {
}.bodyCatching() }.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") { return client.postCatching("/youtubei/v1/music/get_queue") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(body)
GetQueueBody(
context = Context.DefaultWeb,
videoIds = listOf(videoId)
)
)
parameter("key", Key) parameter("key", Key)
parameter("prettyPrint", false) parameter("prettyPrint", false)
} }
.bodyCatching<GetQueueResponse>() .bodyCatching<GetQueueResponse>()
.map { body -> .map { body ->
body.queueDatas?.firstOrNull()?.content?.playlistPanelVideoRenderer?.let { renderer -> body.queueDatas?.mapNotNull { queueData ->
Item.Song( queueData.content?.playlistPanelVideoRenderer?.let { renderer ->
info = Info( Item.Song(
name = renderer.title.text, info = Info(
endpoint = renderer.navigationEndpoint.watchEndpoint name = renderer
), .title
authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0) .text,
?.map { run -> endpoint = renderer
Info.from(run) .navigationEndpoint
} ?: emptyList(), .watchEndpoint
album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0) ),
?.let { run -> authors = renderer
Info.from(run) .longBylineText
}, ?.splitBySeparator()
thumbnail = renderer.thumbnail.thumbnails[0], ?.getOrNull(0)
durationText = renderer.lengthText.text ?.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( suspend fun next(
videoId: String, videoId: String,
playlistId: String?, playlistId: String?,