From 83230e3817ef6ae34aea912a7e26bddbf4154535 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Tue, 27 Sep 2022 16:43:59 +0200 Subject: [PATCH] Redesign PlaylistScreen (#172) --- .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 6 + .../vimusic/enums/ThumbnailRoundness.kt | 7 - .../vimusic/savers/AlbumResultSaver.kt | 2 +- .../vfsfitvnm/vimusic/savers/ResultSaver.kt | 20 +- .../vimusic/savers/StringListResultSaver.kt | 2 +- .../vimusic/savers/StringResultSaver.kt | 2 +- .../savers/YouTubePlaylistOrAlbumSaver.kt | 27 ++ .../vimusic/ui/screens/IntentUriScreen.kt | 3 + .../vimusic/ui/screens/PlaylistScreen.kt | 457 ------------------ .../vimusic/ui/screens/album/AlbumOverview.kt | 18 +- .../vimusic/ui/screens/home/HomeSongList.kt | 5 +- .../localplaylist/LocalPlaylistSongList.kt | 24 +- .../ui/screens/player/PlayerBottomSheet.kt | 23 +- .../vimusic/ui/screens/player/Thumbnail.kt | 3 +- .../ui/screens/playlist/PlaylistScreen.kt | 39 ++ .../ui/screens/playlist/PlaylistSongList.kt | 317 ++++++++++++ .../searchresult/SearchResultScreen.kt | 6 +- .../it/vfsfitvnm/vimusic/ui/views/SongItem.kt | 3 +- .../vimusic/utils/ProduceSaveableState.kt | 24 + .../it/vfsfitvnm/vimusic/utils/Utils.kt | 31 -- .../it/vfsfitvnm/youtubemusic/YouTube.kt | 127 ++--- 21 files changed, 537 insertions(+), 609 deletions(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubePlaylistOrAlbumSaver.kt delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index ea007bb..aa9bd0a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -285,6 +285,9 @@ interface Database { @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") fun search(query: String): Flow> + @Query("SELECT EXISTS(SELECT 1 FROM Playlist WHERE browseId = :browseId)") + fun isImportedPlaylist(browseId: String): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(format: Format) @@ -315,6 +318,9 @@ interface Database { @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(queuedMediaItems: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertSongPlaylistMaps(songPlaylistMaps: List) + @Transaction fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { val song = Song( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt index 01bcb6e..9fd8ba3 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/ThumbnailRoundness.kt @@ -22,11 +22,4 @@ enum class ThumbnailRoundness { Heavy -> RoundedCornerShape(8.dp) } } - - companion object { - val shape: Shape - @Composable - @ReadOnlyComposable - get() = LocalAppearance.current.thumbnailShape - } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumResultSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumResultSaver.kt index 4b9eea3..b3cf4b3 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumResultSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumResultSaver.kt @@ -1,3 +1,3 @@ package it.vfsfitvnm.vimusic.savers -val AlbumResultSaver = ResultSaver.of(AlbumSaver) +val AlbumResultSaver = resultSaver(AlbumSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ResultSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ResultSaver.kt index e750098..763d2c8 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ResultSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/ResultSaver.kt @@ -3,16 +3,14 @@ package it.vfsfitvnm.vimusic.savers import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope -interface ResultSaver : Saver?, Pair> { - companion object { - fun of(saver: Saver) = - object : Saver?, Pair> { - override fun restore(value: Pair) = - value.first?.let(saver::restore)?.let(Result.Companion::success) - ?: value.second?.let(Result.Companion::failure) +interface ResultSaver : Saver?, Pair> - override fun SaverScope.save(value: Result?) = - with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull() - } +fun resultSaver(saver: Saver) = + object : Saver?, Pair> { + override fun restore(value: Pair) = + value.first?.let(saver::restore)?.let(Result.Companion::success) + ?: value.second?.let(Result.Companion::failure) + + override fun SaverScope.save(value: Result?) = + with(saver) { value?.getOrNull()?.let { save(it) } } to value?.exceptionOrNull() } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringListResultSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringListResultSaver.kt index f7da5d5..37c0c69 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringListResultSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringListResultSaver.kt @@ -2,4 +2,4 @@ package it.vfsfitvnm.vimusic.savers import androidx.compose.runtime.saveable.autoSaver -val StringListResultSaver = ResultSaver.of(autoSaver?>()) +val StringListResultSaver = resultSaver(autoSaver?>()) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringResultSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringResultSaver.kt index a5b35aa..1db4d43 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringResultSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/StringResultSaver.kt @@ -2,4 +2,4 @@ package it.vfsfitvnm.vimusic.savers import androidx.compose.runtime.saveable.autoSaver -val StringResultSaver = ResultSaver.of(autoSaver()) +val StringResultSaver = resultSaver(autoSaver()) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubePlaylistOrAlbumSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubePlaylistOrAlbumSaver.kt new file mode 100644 index 0000000..2601e8b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/YouTubePlaylistOrAlbumSaver.kt @@ -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> { + override fun SaverScope.save(value: YouTube.PlaylistOrAlbum): List = 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) = YouTube.PlaylistOrAlbum( + title = value[0] as String?, + authors = (value[1] as List>?)?.let(YouTubeBrowseInfoListSaver::restore), + year = value[2] as String?, + thumbnail = (value[3] as List?)?.let(YouTubeThumbnailSaver::restore), + songs = (value[4] as List>?)?.let(YouTubeSongListSaver::restore), + url = value[5] as String?, + continuation = null + ) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt index c475ea2..27ba93f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt deleted file mode 100644 index 9a0e48c..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt +++ /dev/null @@ -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?>(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) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt index 65520d5..3f641b5 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt @@ -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() ) { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongList.kt index bc2a12d..9a1c867 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongList.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeSongList.kt @@ -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, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongList.kt index a8c17be..5aaec96 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongList.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/localplaylist/LocalPlaylistSongList.kt @@ -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) } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt index 11d51a5..c9c9150 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt @@ -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) ) { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt index 70375e6..5815994 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/Thumbnail.kt @@ -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( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt new file mode 100644 index 0000000..0e53acb --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt new file mode 100644 index 0000000..db94305 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/playlist/PlaylistSongList.kt @@ -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(), + ) { + 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() + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt index 4b3ce98..e46c1a7 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt @@ -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( + 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( + SearchResult( query = query, filter = searchFilter, stateSaver = YouTubePlaylistListSaver, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt index 121d7d9..a0b7672 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt @@ -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() ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt index 0b73804..32ec81f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/ProduceSaveableState.kt @@ -51,6 +51,30 @@ fun produceSaveableState( return state } +@Composable +fun produceSaveableOneShotState( + initialValue: T, + stateSaver: Saver, + @BuilderInference producer: suspend ProduceStateScope.() -> Unit +): State { + 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 produceSaveableOneShotState( initialValue: T, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt index 82ea165..0f88037 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Utils.kt @@ -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" diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index 40ec4bf..fdc38e8 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -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(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>?, val year: String?, val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?, - val items: List?, + val songs: List?, val url: String?, val continuation: String?, ) { - data class Item( - val info: Info, - val authors: List>?, - val durationText: String?, - val album: Info?, - 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(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().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