Redesign PlaylistScreen (#172)

This commit is contained in:
vfsfitvnm 2022-09-27 16:43:59 +02:00
parent 2e3d437c15
commit 83230e3817
21 changed files with 537 additions and 609 deletions

View file

@ -285,6 +285,9 @@ interface Database {
@Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query")
fun search(query: String): Flow<List<DetailedSong>>
@Query("SELECT EXISTS(SELECT 1 FROM Playlist WHERE browseId = :browseId)")
fun isImportedPlaylist(browseId: String): Flow<Boolean>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(format: Format)
@ -315,6 +318,9 @@ interface Database {
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(queuedMediaItems: List<QueuedMediaItem>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertSongPlaylistMaps(songPlaylistMaps: List<SongPlaylistMap>)
@Transaction
fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) {
val song = Song(

View file

@ -22,11 +22,4 @@ enum class ThumbnailRoundness {
Heavy -> RoundedCornerShape(8.dp)
}
}
companion object {
val shape: Shape
@Composable
@ReadOnlyComposable
get() = LocalAppearance.current.thumbnailShape
}
}

View file

@ -1,3 +1,3 @@
package it.vfsfitvnm.vimusic.savers
val AlbumResultSaver = ResultSaver.of(AlbumSaver)
val AlbumResultSaver = resultSaver(AlbumSaver)

View file

@ -3,16 +3,14 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
interface ResultSaver<Original, Saveable> : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
companion object {
fun <Original, Saveable : Any> of(saver: Saver<Original, Saveable>) =
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
override fun restore(value: Pair<Saveable?, Throwable?>) =
value.first?.let(saver::restore)?.let(Result.Companion::success)
?: value.second?.let(Result.Companion::failure)
interface ResultSaver<Original, Saveable> : Saver<Result<Original>?, Pair<Saveable?, Throwable?>>
override fun SaverScope.save(value: Result<Original>?) =
with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull()
}
fun <Original, Saveable : Any> resultSaver(saver: Saver<Original, Saveable>) =
object : Saver<Result<Original>?, Pair<Saveable?, Throwable?>> {
override fun restore(value: Pair<Saveable?, Throwable?>) =
value.first?.let(saver::restore)?.let(Result.Companion::success)
?: value.second?.let(Result.Companion::failure)
override fun SaverScope.save(value: Result<Original>?) =
with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull()
}
}

View file

@ -2,4 +2,4 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.autoSaver
val StringListResultSaver = ResultSaver.of(autoSaver<List<String>?>())
val StringListResultSaver = resultSaver(autoSaver<List<String>?>())

View file

@ -2,4 +2,4 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.autoSaver
val StringResultSaver = ResultSaver.of(autoSaver<String?>())
val StringResultSaver = resultSaver(autoSaver<String?>())

View file

@ -0,0 +1,27 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubePlaylistOrAlbumSaver : Saver<YouTube.PlaylistOrAlbum, List<Any?>> {
override fun SaverScope.save(value: YouTube.PlaylistOrAlbum): List<Any?> = listOf(
value.title,
value.authors?.let { with(YouTubeBrowseInfoListSaver) { save(it) } } ,
value.year,
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } } ,
value.songs?.let { with(YouTubeSongListSaver) { save(it) } },
value.url
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.PlaylistOrAlbum(
title = value[0] as String?,
authors = (value[1] as List<List<Any?>>?)?.let(YouTubeBrowseInfoListSaver::restore),
year = value[2] as String?,
thumbnail = (value[3] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore),
songs = (value[4] as List<List<Any?>>?)?.let(YouTubeSongListSaver::restore),
url = value[5] as String?,
continuation = null
)
}

View file

@ -2,6 +2,7 @@ package it.vfsfitvnm.vimusic.ui.screens
import android.net.Uri
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -40,6 +41,7 @@ import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.TextCard
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.screens.playlist.PlaylistScreen
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
@ -53,6 +55,7 @@ import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalFoundationApi
@ExperimentalAnimationApi
@Composable
fun IntentUriScreen(uri: Uri) {

View file

@ -1,457 +0,0 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.content.Intent
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.styling.shimmer
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.bold
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.relaunchableEffect
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.toMediaItem
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun PlaylistScreen(browseId: String) {
val lazyListState = rememberLazyListState()
RouteHandler(listenToGlobalEmitter = true) {
globalRoutes()
host {
val context = LocalContext.current
val binder = LocalPlayerServiceBinder.current
val (colorPalette, typography) = LocalAppearance.current
val menuState = LocalMenuState.current
val thumbnailSizePx = Dimensions.thumbnails.playlist.px
val songThumbnailSizePx = Dimensions.thumbnails.song.px
var playlist by remember {
mutableStateOf<Result<YouTube.PlaylistOrAlbum>?>(null)
}
val onLoad = relaunchableEffect(Unit) {
playlist = withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(items = playlist.items?.filter { it.info.endpoint != null })
}
}
}
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.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)
)
}
}
item {
playlist?.getOrNull()?.let { playlist ->
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 8.dp)
) {
AsyncImage(
model = playlist.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(Dimensions.thumbnails.playlist)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxSize()
) {
Column {
BasicText(
text = playlist.title ?: "Unknown",
style = typography.m.semiBold
)
BasicText(
text = playlist.authors?.joinToString("") { it.name }
?: "",
style = typography.xs.secondary.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
playlist.year?.let { year ->
BasicText(
text = year,
style = typography.xs.secondary,
maxLines = 1,
modifier = Modifier
.padding(top = 8.dp)
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
.padding(horizontal = 8.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.stopRadio()
playlist.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlist)
}
?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(
mediaItems
)
}
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
menuState.display {
Menu {
MenuEntry(
icon = R.drawable.enqueue,
text = "Enqueue",
onClick = {
menuState.hide()
playlist.items
?.mapNotNull { song ->
song.toMediaItem(
browseId,
playlist
)
}
?.let { mediaItems ->
binder?.player?.enqueue(
mediaItems
)
}
}
)
MenuEntry(
icon = R.drawable.playlist,
text = "Import",
onClick = {
menuState.hide()
transaction {
val playlistId =
Database.insert(
Playlist(
name = playlist.title
?: "Unknown",
browseId = browseId
)
)
playlist.items?.forEachIndexed { index, song ->
song
.toMediaItem(
browseId,
playlist
)
?.let { mediaItem ->
Database.insert(
mediaItem
)
Database.insert(
SongPlaylistMap(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
)
}
}
}
}
)
MenuEntry(
icon = R.drawable.share_social,
text = "Share",
onClick = {
menuState.hide()
(playlist.url
?: "https://music.youtube.com/playlist?list=${
browseId.removePrefix(
"VL"
)
}").let { url ->
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
context.startActivity(
Intent.createChooser(
sendIntent,
null
)
)
}
}
)
}
}
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
)
}
}
} ?: playlist?.exceptionOrNull()?.let { throwable ->
LoadingOrError(
errorMessage = throwable.javaClass.canonicalName,
onRetry = onLoad
)
} ?: LoadingOrError()
}
itemsIndexed(
items = playlist?.getOrNull()?.items ?: emptyList(),
contentType = { _, song -> song }
) { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors
?: playlist?.getOrNull()?.authors)?.joinToString("") { it.name },
durationText = song.durationText,
onClick = {
binder?.stopRadio()
playlist?.getOrNull()?.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlist?.getOrNull()!!)
}?.let { mediaItems ->
binder?.player?.forcePlayAtIndex(mediaItems, index)
}
},
startContent = {
if (song.thumbnail == null) {
BasicText(
text = "${index + 1}",
style = typography.xs.secondary.bold.center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.width(36.dp)
)
} else {
AsyncImage(
model = song.thumbnail!!.size(songThumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(Dimensions.thumbnails.song)
)
}
},
menuContent = {
NonQueuedMediaItemMenu(
mediaItem = song.toMediaItem(
browseId,
playlist?.getOrNull()!!
)
?: return@SongItem,
onDismiss = menuState::hide,
)
}
)
}
}
}
}
}
@Composable
private fun LoadingOrError(
errorMessage: String? = null,
onRetry: (() -> Unit)? = null
) {
val (colorPalette) = LocalAppearance.current
LoadingOrError(
errorMessage = errorMessage,
onRetry = onRetry
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.shimmer, shape = ThumbnailRoundness.shape)
.size(Dimensions.thumbnails.playlist)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxHeight()
) {
Column {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
repeat(3) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.alpha(0.6f - it * 0.1f)
.height(Dimensions.thumbnails.song)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
) {
Spacer(
modifier = Modifier
.size(8.dp)
.background(color = Color.Black, shape = CircleShape)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
}
}

View file

@ -6,7 +6,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -30,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
@ -67,7 +65,6 @@ import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.thumbnail
import it.vfsfitvnm.vimusic.utils.toMediaItem
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
@ -101,16 +98,16 @@ fun AlbumOverview(
shareUrl = youtubeAlbum.url,
timestamp = System.currentTimeMillis()
),
youtubeAlbum.items?.mapIndexedNotNull { position, albumItem ->
albumItem.toMediaItem(browseId, youtubeAlbum)?.let { mediaItem ->
Database.insert(mediaItem)
youtubeAlbum.songs
?.map(YouTube.Item.Song::asMediaItem)
?.onEach(Database::insert)
?.mapIndexed { position, mediaItem ->
SongAlbumMap(
songId = mediaItem.mediaId,
albumId = browseId,
position = position
)
}
} ?: emptyList()
} ?: emptyList()
)
null
@ -298,11 +295,6 @@ fun AlbumOverview(
} ?: albumResult?.exceptionOrNull()?.let {
Box(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
// viewModel.fetch(browseId)
}
}
.align(Alignment.Center)
.fillMaxSize()
) {

View file

@ -42,7 +42,6 @@ import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.SongSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
@ -68,7 +67,7 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun HomeSongList() {
val (colorPalette, typography) = LocalAppearance.current
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val thumbnailSize = Dimensions.thumbnails.song.px
@ -193,7 +192,7 @@ fun HomeSongList() {
Color.Black.copy(alpha = 0.75f)
)
),
shape = ThumbnailRoundness.shape
shape = thumbnailShape
)
.padding(
horizontal = 8.dp,

View file

@ -53,7 +53,6 @@ import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.toMediaItem
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
@ -177,25 +176,22 @@ fun LocalPlaylistSongList(
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(items = playlist.items?.filter { it.info.endpoint != null })
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
}
}?.getOrNull()?.let { remotePlaylist ->
Database.clearPlaylist(playlistId)
remotePlaylist.items?.forEachIndexed { index, song ->
song.toMediaItem(browseId, remotePlaylist)?.let { mediaItem ->
Database.insert(mediaItem)
Database.insert(
SongPlaylistMap(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
remotePlaylist.songs
?.map(YouTube.Item.Song::asMediaItem)
?.onEach(Database::insert)
?.mapIndexed { position, mediaItem ->
SongPlaylistMap(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = position
)
}
}
}?.let(Database::insertSongPlaylistMaps)
}
}
}

View file

@ -8,7 +8,21 @@ 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.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -35,16 +49,15 @@ import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.reorder
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.onOverlay
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
@ -63,7 +76,7 @@ fun PlayerBottomSheet(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val (colorPalette, typography) = LocalAppearance.current
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
BottomSheet(
state = layoutState,
@ -168,7 +181,7 @@ fun PlayerBottomSheet(
modifier = Modifier
.background(
color = Color.Black.copy(alpha = 0.25f),
shape = ThumbnailRoundness.shape
shape = thumbnailShape
)
.size(Dimensions.thumbnails.song)
) {

View file

@ -33,6 +33,7 @@ import it.vfsfitvnm.vimusic.service.LoginRequiredException
import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException
import it.vfsfitvnm.vimusic.service.UnplayableException
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.rememberError
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
@ -99,7 +100,7 @@ fun Thumbnail(
Box(
modifier = modifier
.aspectRatio(1f)
.clip(ThumbnailRoundness.shape)
.clip(LocalAppearance.current.thumbnailShape)
.size(thumbnailSizeDp)
) {
AsyncImage(

View file

@ -0,0 +1,39 @@
package it.vfsfitvnm.vimusic.ui.screens.playlist
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
@ExperimentalFoundationApi
@ExperimentalAnimationApi
@Composable
fun PlaylistScreen(browseId: String) {
val saveableStateHolder = rememberSaveableStateHolder()
RouteHandler(listenToGlobalEmitter = true) {
globalRoutes()
host {
Scaffold(
topIconButtonId = R.drawable.chevron_back,
onTopIconButtonClick = pop,
tabIndex = 0,
onTabChanged = { },
tabColumnContent = { Item ->
Item(0, "Songs", R.drawable.musical_notes)
}
) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
PlaylistSongList(
browseId = browseId
)
}
}
}
}
}

View file

@ -0,0 +1,317 @@
package it.vfsfitvnm.vimusic.ui.screens.playlist
import android.content.Intent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
import it.vfsfitvnm.vimusic.savers.YouTubePlaylistOrAlbumSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.styling.shimmer
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.produceSaveableOneShotState
import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@Composable
fun PlaylistSongList(
browseId: String,
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val context = LocalContext.current
val playlistResult by produceSaveableOneShotState(
initialValue = null,
stateSaver = resultSaver(YouTubePlaylistOrAlbumSaver),
) {
value = withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(songs = playlist.songs?.filter { it.info.endpoint != null })
}
}
}
val isImported by produceSaveableState(
initialValue = null,
stateSaver = autoSaver<Boolean?>(),
) {
Database
.isImportedPlaylist(browseId)
.flowOn(Dispatchers.IO)
.collect { value = it }
}
BoxWithConstraints {
val thumbnailSizeDp = maxWidth - Dimensions.verticalBarWidth
val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px
val songThumbnailSizeDp = Dimensions.thumbnails.song
val songThumbnailSizePx = songThumbnailSizeDp.px
playlistResult?.getOrNull()?.let { playlist ->
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Column {
Header(title = playlist.title ?: "Unknown") {
if (playlist.songs?.isNotEmpty() == true) {
BasicText(
text = "Enqueue",
style = typography.xxs.medium,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable {
playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems ->
binder?.player?.enqueue(mediaItems)
}
}
.background(colorPalette.background2)
.padding(all = 8.dp)
.padding(horizontal = 8.dp)
)
}
Spacer(
modifier = Modifier
.weight(1f)
)
Image(
painter = painterResource(
if (isImported == true) R.drawable.bookmark else R.drawable.bookmark_outline
),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.accent),
modifier = Modifier
.clickable(enabled = isImported == false) {
transaction {
val playlistId =
Database.insert(
Playlist(
name = playlist.title ?: "Unknown",
browseId = browseId
)
)
playlist.songs
?.map(YouTube.Item.Song::asMediaItem)
?.onEach(Database::insert)
?.mapIndexed { index, mediaItem ->
SongPlaylistMap(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
}?.let(Database::insertSongPlaylistMaps)
}
}
.padding(all = 4.dp)
.size(18.dp)
)
Image(
painter = painterResource(R.drawable.share_social),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
(playlist.url ?: "https://music.youtube.com/playlist?list=${browseId.removePrefix("VL")}").let { url ->
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
context.startActivity(Intent.createChooser(sendIntent, null))
}
}
.padding(all = 4.dp)
.size(18.dp)
)
}
AsyncImage(
model = playlist.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(thumbnailShape)
.size(thumbnailSizeDp)
)
}
}
itemsIndexed(items = playlist.songs ?: emptyList()) { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors ?: playlist.authors)?.joinToString("") { it.name },
durationText = song.durationText,
onClick = {
playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems ->
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(mediaItems, index)
}
},
startContent = {
AsyncImage(
model = song.thumbnail?.size(songThumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(thumbnailShape)
.size(Dimensions.thumbnails.song)
)
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
}
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(all = 16.dp)
.padding(LocalPlayerAwarePaddingValues.current)
.clip(RoundedCornerShape(16.dp))
.clickable(enabled = playlist.songs?.isNotEmpty() == true) {
playlist.songs?.map(YouTube.Item.Song::asMediaItem)?.let { mediaItems ->
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(mediaItems.shuffled())
}
}
.background(colorPalette.background2)
.size(62.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.align(Alignment.Center)
.size(20.dp)
)
}
} ?: playlistResult?.exceptionOrNull()?.let {
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxSize()
) {
BasicText(
text = "An error has occurred.\nTap to retry",
style = typography.s.medium.secondary.center,
modifier = Modifier
.align(Alignment.Center)
)
}
} ?: Column(
modifier = Modifier
.padding(LocalPlayerAwarePaddingValues.current)
.shimmer()
.fillMaxSize()
) {
HeaderPlaceholder()
Spacer(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(thumbnailShape)
.size(thumbnailSizeDp)
.background(colorPalette.shimmer)
)
repeat(3) { index ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.alpha(1f - index * 0.25f)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = Dimensions.itemsVerticalPadding)
.height(Dimensions.thumbnails.song)
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.shimmer, shape = thumbnailShape)
.size(Dimensions.thumbnails.song)
)
Column {
TextPlaceholder()
TextPlaceholder()
}
}
}
}
}
}

View file

@ -19,10 +19,10 @@ import it.vfsfitvnm.vimusic.savers.YouTubePlaylistListSaver
import it.vfsfitvnm.vimusic.savers.YouTubeSongListSaver
import it.vfsfitvnm.vimusic.savers.YouTubeVideoListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
import it.vfsfitvnm.vimusic.ui.screens.PlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
import it.vfsfitvnm.vimusic.ui.screens.artistRoute
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
import it.vfsfitvnm.vimusic.ui.screens.playlist.PlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.playlistRoute
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.px
@ -173,7 +173,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailHeightDp = 72.dp
val thumbnailWidthDp = 128.dp
SearchResult<YouTube.Item.Video>(
SearchResult(
query = query,
filter = searchFilter,
stateSaver = YouTubeVideoListSaver,
@ -203,7 +203,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
SearchResult<YouTube.Item.Playlist>(
SearchResult(
query = query,
filter = searchFilter,
stateSaver = YouTubePlaylistListSaver,

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
@ -115,7 +114,7 @@ fun SongItem(
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.clip(LocalAppearance.current.thumbnailShape)
.fillMaxSize()
)

View file

@ -51,6 +51,30 @@ fun <T> produceSaveableState(
return state
}
@Composable
fun <T> produceSaveableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val state = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue)
}
var produced by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(Unit) {
if (!produced) {
ProduceSaveableStateScope(state, coroutineContext).producer()
produced = true
}
}
return state
}
@Composable
fun <T> produceSaveableOneShotState(
initialValue: T,

View file

@ -89,37 +89,6 @@ val DetailedSong.asMediaItem: MediaItem
.setCustomCacheKey(id)
.build()
fun YouTube.PlaylistOrAlbum.Item.toMediaItem(
albumId: String,
playlistOrAlbum: YouTube.PlaylistOrAlbum
): MediaItem? {
val isFromAlbum = thumbnail == null
return MediaItem.Builder()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist((authors ?: playlistOrAlbum.authors)?.joinToString("") { it.name })
.setAlbumTitle(if (isFromAlbum) playlistOrAlbum.title else album?.name)
.setArtworkUri(if (isFromAlbum) playlistOrAlbum.thumbnail?.url?.toUri() else thumbnail?.url?.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint?.videoId,
"playlistId" to info.endpoint?.playlistId,
"albumId" to (if (isFromAlbum) albumId else album?.endpoint?.browseId),
"durationText" to durationText,
"artistNames" to (authors ?: playlistOrAlbum.authors)?.filter { it.endpoint != null }?.map { it.name },
"artistIds" to (authors ?: playlistOrAlbum.authors)?.mapNotNull { it.endpoint?.browseId }
)
)
.build()
)
.setMediaId(info.endpoint?.videoId ?: return null)
.setUri(info.endpoint?.videoId ?: return null)
.setCustomCacheKey(info.endpoint?.videoId ?: return null)
.build()
}
fun String?.thumbnail(size: Int): String? {
return when {
this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$size-h$size"

View file

@ -226,6 +226,49 @@ object YouTube {
.thumbnail
)
}
fun from(renderer: MusicResponsiveListItemRenderer): Song? {
return Song(
info = renderer
.flexColumns
.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.let { Info.from(it) } ?: return null,
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) }
?.takeIf { it.isNotEmpty() },
durationText = renderer
.fixedColumns
?.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.text,
album = renderer
.flexColumns
.getOrNull(2)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.firstOrNull()
?.let { Info.from(it) },
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
)
}
}
}
@ -817,63 +860,10 @@ object YouTube {
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
val items: List<Item>?,
val songs: List<Item.Song>?,
val url: String?,
val continuation: String?,
) {
data class Item(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val durationText: String?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
) {
companion object {
fun from(renderer: MusicResponsiveListItemRenderer): Item? {
return Item(
info = renderer
.flexColumns
.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.let { Info.from(it) } ?: return null,
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) }
?.takeIf { it.isNotEmpty() },
durationText = renderer
.fixedColumns
?.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.text,
album = renderer
.flexColumns
.getOrNull(2)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.firstOrNull()
?.let { Info.from(it) },
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
)
}
}
}
suspend fun next(): PlaylistOrAlbum {
return continuation?.let {
runCatching {
@ -885,12 +875,12 @@ object YouTube {
parameter("continuation", continuation)
}.body<ContinuationResponse>().let { continuationResponse ->
copy(
items = items?.plus(continuationResponse
songs = songs?.plus(continuationResponse
.continuationContents
.musicShelfContinuation
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Companion::from) ?: emptyList()),
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()),
continuation = continuationResponse
.continuationContents
.musicShelfContinuation
@ -909,9 +899,28 @@ object YouTube {
return playlistOrAlbum(browseId)?.map { album ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
playlistOrAlbum("VL$playlistId")?.getOrNull()?.let { playlist ->
album.copy(items = playlist.items)
album.copy(songs = playlist.songs)
}
} ?: album
}?.map { album ->
val albumInfo = Info(
name = album.title ?: "",
endpoint = NavigationEndpoint.Endpoint.Browse(
browseId = browseId,
params = null,
browseEndpointContextSupportedConfigs = null
)
)
album.copy(
songs = album.songs?.map { song ->
song.copy(
authors = song.authors ?: album.authors,
album = albumInfo,
thumbnail = album.thumbnail
)
}
)
}
}
@ -950,7 +959,7 @@ object YouTube {
?.getOrNull(2)
?.firstOrNull()
?.text,
items = body
songs = body
.contents
.singleColumnBrowseResultsRenderer
?.tabs
@ -963,7 +972,7 @@ object YouTube {
?.musicShelfRenderer
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(PlaylistOrAlbum.Item.Companion::from)
?.mapNotNull(Item.Song.Companion::from)
// ?.filter { it.info.endpoint != null }
,
url = body