Add the ability to open YouTube playlist links
This commit is contained in:
parent
756eb48176
commit
c3b2435623
7 changed files with 235 additions and 146 deletions
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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?,
|
||||
|
|
Loading…
Reference in a new issue