Redesign LocalPlaylistScreen (#172)

This commit is contained in:
vfsfitvnm 2022-09-27 15:17:27 +02:00
parent 9d1ed51d61
commit 2e3d437c15
8 changed files with 378 additions and 353 deletions

View file

@ -19,9 +19,4 @@ data class PlaylistWithSongs(
)
)
val songs: List<DetailedSong>
) {
companion object {
val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList())
val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList())
}
}
)

View file

@ -5,7 +5,7 @@ import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.models.DetailedSong
object DetailedSongSaver : Saver<DetailedSong, List<Any?>> {
override fun SaverScope.save(value: DetailedSong): List<Any?> =
override fun SaverScope.save(value: DetailedSong) =
listOf(
value.id,
value.title,
@ -18,16 +18,14 @@ object DetailedSongSaver : Saver<DetailedSong, List<Any?>> {
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>): DetailedSong? {
return if (value.size == 8) DetailedSong(
id = value[0] as String,
title = value[1] as String,
artistsText = value[2] as String?,
durationText = value[3] as String,
thumbnailUrl = value[4] as String?,
totalPlayTimeMs = value[5] as Long,
albumId = value[6] as String?,
artists = InfoListSaver.restore(value[7] as List<List<String>>)
) else null
}
override fun restore(value: List<Any?>) = DetailedSong(
id = value[0] as String,
title = value[1] as String,
artistsText = value[2] as String?,
durationText = value[3] as String,
thumbnailUrl = value[4] as String?,
totalPlayTimeMs = value[5] as Long,
albumId = value[6] as String?,
artists = InfoListSaver.restore(value[7] as List<List<String>>)
)
}

View file

@ -4,6 +4,9 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
interface ListSaver<Original, Saveable : Any> : Saver<List<Original>, List<Saveable>> {
override fun SaverScope.save(value: List<Original>): List<Saveable>
override fun restore(value: List<Saveable>): List<Original>
companion object {
fun <Original, Saveable : Any> of(saver: Saver<Original, Saveable>): ListSaver<Original, Saveable> {
return object : ListSaver<Original, Saveable> {

View file

@ -0,0 +1,20 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
object PlaylistWithSongsSaver : Saver<PlaylistWithSongs?, List<Any>> {
override fun SaverScope.save(value: PlaylistWithSongs?) = value?.let {
listOf(
with(PlaylistSaver) { save(value.playlist) },
with(DetailedSongListSaver) { save(value.songs) },
)
}
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any>): PlaylistWithSongs = PlaylistWithSongs(
playlist = PlaylistSaver.restore(value[0] as List<Any?>),
songs = DetailedSongListSaver.restore(value[1] as List<List<Any?>>)
)
}

View file

@ -1,333 +0,0 @@
package it.vfsfitvnm.vimusic.ui.screens
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.Column
import androidx.compose.foundation.layout.Row
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.reorder
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.models.DetailedSong
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
import it.vfsfitvnm.vimusic.query
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.ConfirmationDialog
import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
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.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
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.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ExperimentalFoundationApi
@ExperimentalAnimationApi
@Composable
fun LocalPlaylistScreen(playlistId: Long) {
val playlistWithSongs by remember(playlistId) {
Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound }
}.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO)
val lazyListState = rememberLazyListState()
RouteHandler(listenToGlobalEmitter = true) {
globalRoutes()
host {
val (colorPalette, typography) = LocalAppearance.current
val menuState = LocalMenuState.current
val binder = LocalPlayerServiceBinder.current
val thumbnailSize = Dimensions.thumbnails.song.px
val reorderingState = rememberReorderingState(
lazyListState = lazyListState,
key = playlistWithSongs.songs,
onDragEnd = { fromIndex, toIndex ->
query {
Database.move(playlistWithSongs.playlist.id, fromIndex, toIndex)
}
},
extraItemCount = 1
)
var isRenaming by rememberSaveable {
mutableStateOf(false)
}
if (isRenaming) {
TextFieldDialog(
hintText = "Enter the playlist name",
initialTextInput = playlistWithSongs.playlist.name,
onDismiss = { isRenaming = false },
onDone = { text ->
query {
Database.update(playlistWithSongs.playlist.copy(name = text))
}
}
)
}
var isDeleting by rememberSaveable {
mutableStateOf(false)
}
if (isDeleting) {
ConfirmationDialog(
text = "Do you really want to delete this playlist?",
onDismiss = { isDeleting = false },
onConfirm = {
query {
Database.delete(playlistWithSongs.playlist)
}
pop()
}
)
}
ReorderingLazyColumn(
reorderingState = reorderingState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item {
Column {
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, horizontal = 16.dp)
.size(24.dp)
)
}
Column(
modifier = Modifier
.padding(top = 16.dp, bottom = 8.dp)
.padding(horizontal = 16.dp)
) {
BasicText(
text = playlistWithSongs.playlist.name,
style = typography.m.semiBold
)
BasicText(
text = "${playlistWithSongs.songs.size} songs",
style = typography.xxs.semiBold.secondary
)
}
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(enabled = playlistWithSongs.songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
playlistWithSongs.songs
.shuffled()
.map(DetailedSong::asMediaItem)
)
}
.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",
isEnabled = playlistWithSongs.songs.isNotEmpty(),
onClick = {
menuState.hide()
binder?.player?.enqueue(
playlistWithSongs.songs.map(
DetailedSong::asMediaItem
)
)
}
)
MenuEntry(
icon = R.drawable.pencil,
text = "Rename",
onClick = {
menuState.hide()
isRenaming = true
}
)
playlistWithSongs.playlist.browseId?.let { browseId ->
MenuEntry(
icon = R.drawable.sync,
text = "Sync",
onClick = {
menuState.hide()
transaction {
runBlocking(Dispatchers.IO) {
withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(items = playlist.items?.filter { it.info.endpoint != null })
}
}
}?.getOrNull()?.let { remotePlaylist ->
Database.clearPlaylist(playlistWithSongs.playlist.id)
remotePlaylist.items?.forEachIndexed { index, song ->
song.toMediaItem(browseId, remotePlaylist)?.let { mediaItem ->
Database.insert(mediaItem)
Database.insert(
SongPlaylistMap(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
)
}
}
}
}
}
)
}
MenuEntry(
icon = R.drawable.trash,
text = "Delete",
onClick = {
menuState.hide()
isDeleting = true
}
)
}
}
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
)
}
}
}
itemsIndexed(
items = playlistWithSongs.songs,
key = { _, song -> song.id },
contentType = { _, song -> song },
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
playlistWithSongs.songs.map(
DetailedSong::asMediaItem
), index
)
},
menuContent = {
InPlaylistMediaItemMenu(
playlistId = playlistId,
positionInPlaylist = index,
song = song
)
},
trailingContent = {
Image(
painter = painterResource(R.drawable.reorder),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable { }
.reorder(
reorderingState = reorderingState,
index = index
)
.padding(horizontal = 8.dp, vertical = 4.dp)
.size(20.dp)
)
},
modifier = Modifier
.animateItemPlacement(reorderingState = reorderingState)
.draggedItem(reorderingState = reorderingState, index = index)
)
}
}
}
}
}

View file

@ -13,7 +13,7 @@ import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
import it.vfsfitvnm.vimusic.ui.screens.BuiltInPlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
import it.vfsfitvnm.vimusic.ui.screens.LocalPlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.localplaylist.LocalPlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
import it.vfsfitvnm.vimusic.ui.screens.artistRoute
import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute

View file

@ -0,0 +1,40 @@
package it.vfsfitvnm.vimusic.ui.screens.localplaylist
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 LocalPlaylistScreen(playlistId: Long) {
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) {
LocalPlaylistSongList(
playlistId = playlistId,
onDelete = pop
)
}
}
}
}
}

View file

@ -0,0 +1,302 @@
package it.vfsfitvnm.vimusic.ui.screens.localplaylist
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.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.reordering.ReorderingLazyColumn
import it.vfsfitvnm.reordering.animateItemPlacement
import it.vfsfitvnm.reordering.draggedItem
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.reorder
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.DetailedSong
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.savers.PlaylistWithSongsSaver
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
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.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
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.produceSaveableState
import it.vfsfitvnm.vimusic.utils.toMediaItem
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@Composable
fun LocalPlaylistSongList(
playlistId: Long,
onDelete: () -> Unit,
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val playlistWithSongs by produceSaveableState(
initialValue = null,
stateSaver = PlaylistWithSongsSaver
) {
Database
.playlistWithSongs(playlistId)
.flowOn(Dispatchers.IO)
.collect { value = it }
}
val lazyListState = rememberLazyListState()
val reorderingState = rememberReorderingState(
lazyListState = lazyListState,
key = playlistWithSongs?.songs ?: emptyList<Any>(),
onDragEnd = { fromIndex, toIndex ->
query {
Database.move(playlistId, fromIndex, toIndex)
}
},
extraItemCount = 1
)
var isRenaming by rememberSaveable {
mutableStateOf(false)
}
if (isRenaming) {
TextFieldDialog(
hintText = "Enter the playlist name",
initialTextInput = playlistWithSongs?.playlist?.name ?: "",
onDismiss = { isRenaming = false },
onDone = { text ->
query {
playlistWithSongs?.playlist?.copy(name = text)?.let(Database::update)
}
}
)
}
var isDeleting by rememberSaveable {
mutableStateOf(false)
}
if (isDeleting) {
ConfirmationDialog(
text = "Do you really want to delete this playlist?",
onDismiss = { isDeleting = false },
onConfirm = {
query {
playlistWithSongs?.playlist?.let(Database::delete)
}
onDelete()
}
)
}
val thumbnailSize = Dimensions.thumbnails.song.px
Box {
ReorderingLazyColumn(
reorderingState = reorderingState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Header(title = playlistWithSongs?.playlist?.name ?: "Unknown") {
BasicText(
text = "Enqueue",
style = typography.xxs.medium,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable(enabled = playlistWithSongs?.songs?.isNotEmpty() == true) {
playlistWithSongs?.songs
?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->
binder?.player?.enqueue(mediaItems)
}
}
.background(colorPalette.background2)
.padding(all = 8.dp)
.padding(horizontal = 8.dp)
)
Spacer(
modifier = Modifier
.weight(1f)
)
playlistWithSongs?.playlist?.browseId?.let { browseId ->
Image(
painter = painterResource(R.drawable.sync),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
transaction {
runBlocking(Dispatchers.IO) {
withContext(Dispatchers.IO) {
YouTube.playlist(browseId)?.map {
it.next()
}?.map { playlist ->
playlist.copy(items = playlist.items?.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
)
)
}
}
}
}
}
.padding(all = 4.dp)
.size(18.dp)
)
}
Image(
painter = painterResource(R.drawable.pencil),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable { isRenaming = true }
.padding(all = 4.dp)
.size(18.dp)
)
Image(
painter = painterResource(R.drawable.trash),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable { isDeleting = true }
.padding(all = 4.dp)
.size(18.dp)
)
}
}
itemsIndexed(
items = playlistWithSongs?.songs ?: emptyList(),
key = { _, song -> song.id },
contentType = { _, song -> song },
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
onClick = {
playlistWithSongs?.songs?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(mediaItems, index)
}
},
menuContent = {
InPlaylistMediaItemMenu(
playlistId = playlistId,
positionInPlaylist = index,
song = song
)
},
trailingContent = {
Image(
painter = painterResource(R.drawable.reorder),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable { }
.reorder(
reorderingState = reorderingState,
index = index
)
.padding(horizontal = 8.dp, vertical = 4.dp)
.size(20.dp)
)
},
modifier = Modifier
.animateItemPlacement(reorderingState = reorderingState)
.draggedItem(reorderingState = reorderingState, index = index)
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(all = 16.dp)
.padding(LocalPlayerAwarePaddingValues.current)
.clip(RoundedCornerShape(16.dp))
.clickable(enabled = playlistWithSongs?.songs?.isNotEmpty() == true) {
playlistWithSongs?.songs
?.shuffled()
?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(mediaItems)
}
}
.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)
)
}
}
}