Redesign PlaylistScreen (#172)
This commit is contained in:
parent
2e3d437c15
commit
83230e3817
21 changed files with 537 additions and 609 deletions
|
@ -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(
|
||||
|
|
|
@ -22,11 +22,4 @@ enum class ThumbnailRoundness {
|
|||
Heavy -> RoundedCornerShape(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shape: Shape
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalAppearance.current.thumbnailShape
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
package it.vfsfitvnm.vimusic.savers
|
||||
|
||||
val AlbumResultSaver = ResultSaver.of(AlbumSaver)
|
||||
val AlbumResultSaver = resultSaver(AlbumSaver)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>?>())
|
||||
|
|
|
@ -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?>())
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue