From d0e9c7e6b95bfd6a7f6d5d202138784e829ab65d Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Mon, 3 Oct 2022 15:20:41 +0200 Subject: [PATCH] Add "Other versions" tab in AlbumScreen --- .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 3 + .../it/vfsfitvnm/vimusic/savers/AlbumSaver.kt | 2 - .../InnertubePlaylistOrAlbumPageSaver.kt | 4 +- .../ui/components/themed/ShimmerHost.kt | 7 +- .../vimusic/ui/screens/album/AlbumOverview.kt | 328 ----------------- .../vimusic/ui/screens/album/AlbumScreen.kt | 342 +++++++++++++----- .../vimusic/ui/screens/album/AlbumSongs.kt | 143 ++++++++ .../ui/screens/artist/ArtistLocalSongsList.kt | 2 +- .../vimusic/ui/screens/artist/ArtistScreen.kt | 10 +- .../ui/screens/searchresult/ItemsPage.kt | 2 +- .../searchresult/SearchResultScreen.kt | 10 +- .../it/vfsfitvnm/youtubemusic/Innertube.kt | 3 +- .../models/MusicCarouselShelfRenderer.kt | 2 +- .../youtubemusic/requests/ArtistPage.kt | 4 +- .../youtubemusic/requests/PlaylistPage.kt | 17 +- 15 files changed, 440 insertions(+), 439 deletions(-) delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 19f90e4..0b4d559 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -183,6 +183,9 @@ interface Database { @Query("SELECT * FROM Album WHERE id = :id") fun album(id: String): Flow + @Query("SELECT timestamp FROM Album WHERE id = :id") + fun albumTimestamp(id: String): Long? + @Transaction @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") @RewriteQueriesToDropUnusedColumns diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt index 7825d4b..2a62bb4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/AlbumSaver.kt @@ -28,6 +28,4 @@ object AlbumSaver : Saver> { ) } -val AlbumResultSaver = resultSaver(AlbumSaver) - val AlbumListSaver = listSaver(AlbumSaver) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt index 3abd5cd..de0fdd9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/savers/InnertubePlaylistOrAlbumPageSaver.kt @@ -7,11 +7,12 @@ import it.vfsfitvnm.youtubemusic.Innertube object InnertubePlaylistOrAlbumPageSaver : Saver> { override fun SaverScope.save(value: Innertube.PlaylistOrAlbumPage): List = listOf( value.title, - value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } } , + value.authors?.let { with(InnertubeBrowseInfoListSaver) { save(it) } }, value.year, value.thumbnail?.let { with(InnertubeThumbnailSaver) { save(it) } } , value.url, value.songsPage?.let { with(InnertubeSongsPageSaver) { save(it) } }, + value.otherVersions?.let { with(InnertubeAlbumItemListSaver) { save(it) } }, ) @Suppress("UNCHECKED_CAST") @@ -22,5 +23,6 @@ object InnertubePlaylistOrAlbumPageSaver : Saver?)?.let(InnertubeThumbnailSaver::restore), url = value[4] as String?, songsPage = (value[5] as List?)?.let(InnertubeSongsPageSaver::restore), + otherVersions = (value[6] as List>?)?.let(InnertubeAlbumItemListSaver::restore), ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/ShimmerHost.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/ShimmerHost.kt index 98bf7ba..bbed73d 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/ShimmerHost.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/ShimmerHost.kt @@ -3,6 +3,7 @@ package it.vfsfitvnm.vimusic.ui.components.themed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode @@ -12,8 +13,12 @@ import androidx.compose.ui.graphics.graphicsLayer import com.valentinilk.shimmer.shimmer @Composable -fun ShimmerHost(content: @Composable ColumnScope.() -> Unit) { +fun ShimmerHost( + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit, +) { Column( + horizontalAlignment = horizontalAlignment, modifier = Modifier .shimmer() .graphicsLayer(alpha = 0.99f) 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 deleted file mode 100644 index bb2c190..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumOverview.kt +++ /dev/null @@ -1,328 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens.album - -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -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.Album -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.models.SongAlbumMap -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.savers.AlbumResultSaver -import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver -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.PrimaryButton -import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton -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.color -import it.vfsfitvnm.vimusic.utils.enqueue -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -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.youtubemusic.Innertube -import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody -import it.vfsfitvnm.youtubemusic.requests.albumPage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@ExperimentalFoundationApi -@Composable -fun AlbumOverview( - browseId: String, -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - val context = LocalContext.current - - val albumResult by produceSaveableState( - initialValue = null, - stateSaver = AlbumResultSaver, - ) { - withContext(Dispatchers.IO) { - Database.album(browseId).collect { album -> - if (album?.timestamp == null) { - Innertube.albumPage(BrowseBody(browseId = browseId))?.onSuccess { albumPage -> - Database.upsert( - Album( - id = browseId, - title = albumPage.title, - thumbnailUrl = albumPage.thumbnail?.url, - year = albumPage.year, - authorsText = albumPage.authors?.joinToString("") { it.name ?: "" }, - shareUrl = albumPage.url, - timestamp = System.currentTimeMillis() - ), - albumPage - .songsPage - ?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { position, mediaItem -> - SongAlbumMap( - songId = mediaItem.mediaId, - albumId = browseId, - position = position - ) - } ?: emptyList() - ) - }?.onFailure { throwable -> - value = Result.failure(throwable) - } - } else { - value = Result.success(album) - } - } - } - } - - val songs by produceSaveableState( - initialValue = emptyList(), - stateSaver = DetailedSongListSaver - ) { - Database - .albumSongs(browseId) - .flowOn(Dispatchers.IO) - .collect { value = it } - } - - BoxWithConstraints { - val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth - val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px - - albumResult?.getOrNull()?.let { album -> - LazyColumn( - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 - ) { - Column { - Header(title = album.title ?: "Unknown") { - SecondaryTextButton( - text = "Enqueue", - isEnabled = songs.isNotEmpty(), - onClick = { - binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) - } - ) - - Spacer( - modifier = Modifier - .weight(1f) - ) - - Image( - painter = painterResource( - if (album.bookmarkedAt == null) { - R.drawable.bookmark_outline - } else { - R.drawable.bookmark - } - ), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.accent), - modifier = Modifier - .clickable { - query { - Database.update( - album.copy( - bookmarkedAt = if (album.bookmarkedAt == null) { - System.currentTimeMillis() - } else { - null - } - ) - ) - } - } - .padding(all = 4.dp) - .size(18.dp) - ) - - Image( - painter = painterResource(R.drawable.share_social), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - album.shareUrl?.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 = album.thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(all = 16.dp) - .clip(thumbnailShape) - .size(thumbnailSizeDp) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id } - ) { index, song -> - SongItem( - title = song.title, - authors = song.artistsText ?: album.authorsText, - durationText = song.durationText, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(DetailedSong::asMediaItem), - index - ) - }, - startContent = { - BasicText( - text = "${index + 1}", - style = typography.s.semiBold.center.color(colorPalette.textDisabled), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(Dimensions.thumbnails.song) - ) - }, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) - } - ) - } - } - - PrimaryButton( - iconId = R.drawable.shuffle, - isEnabled = songs.isNotEmpty(), - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(DetailedSong::asMediaItem) - ) - } - ) - } ?: albumResult?.exceptionOrNull()?.let { - Box( - modifier = Modifier - .align(Alignment.Center) - .fillMaxSize() - ) { - BasicText( - text = "An error has occurred.", - style = typography.s.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/album/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt index dad80ec..899ffc4 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumScreen.kt @@ -1,97 +1,67 @@ package it.vfsfitvnm.vimusic.ui.screens.album +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.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +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.saveable.rememberSaveableStateHolder +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.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.route.RouteHandler +import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.Album +import it.vfsfitvnm.vimusic.models.SongAlbumMap +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.savers.AlbumSaver +import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver +import it.vfsfitvnm.vimusic.savers.InnertubePlaylistOrAlbumPageSaver +import it.vfsfitvnm.vimusic.savers.nullableSaver +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes - -//@Stable -//class AlbumScreenState( -// initialIsLoading: Boolean = false, -// initialError: Throwable? = null, -// initialAlbum: Album? = null, -// initialYouTubeAlbum: YouTube.PlaylistOrAlbum? = null, -//) { -// var isLoading by mutableStateOf(initialIsLoading) -// var error by mutableStateOf(initialError) -// var album by mutableStateOf(initialAlbum) -// var youtubeAlbum by mutableStateOf(initialYouTubeAlbum) -// -// suspend fun loadAlbum(browseId: String) { -// println("loadAlbum $browseId") -// Database.album(browseId).flowOn(Dispatchers.IO).collect { -// if (it == null) { -// loadYouTubeAlbum(browseId) -// } else { -// album = it -// } -// } -// } -// -// suspend fun loadYouTubeAlbum(browseId: String) { -// println("loadYouTubeAlbum $browseId") -// if (youtubeAlbum == null) { -// isLoading = true -// withContext(Dispatchers.IO) { -// YouTube.album(browseId) -// }?.onSuccess { -// youtubeAlbum = it -// isLoading = false -// -// query { -// Database.upsert( -// Album( -// id = browseId, -// title = it.title, -// thumbnailUrl = it.thumbnail?.url, -// year = it.year, -// authorsText = it.authors?.joinToString( -// "", -// transform = YouTube.Info::name -// ), -// shareUrl = it.url, -// timestamp = System.currentTimeMillis() -// ), -// it.items?.mapIndexedNotNull { position, albumItem -> -// albumItem.toMediaItem(browseId, it)?.let { mediaItem -> -// Database.insert(mediaItem) -// SongAlbumMap( -// songId = mediaItem.mediaId, -// albumId = browseId, -// position = position -// ) -// } -// } ?: emptyList() -// ) -// } -// -// }?.onFailure { -// error = it -// isLoading = false -// } -// } -// } -//} -// -//object AlbumScreenStateSaver : Saver> { -// override fun restore(value: List) = AlbumScreenState( -// initialIsLoading = value[0] as Boolean, -// initialError = value[1] as Throwable?, -// initialAlbum = (value[1] as List?)?.let(AlbumSaver::restore), -// ) -// -// override fun SaverScope.save(value: AlbumScreenState): List = -// listOf( -// value.isLoading, -// value.error, -// value.album?.let { with(AlbumSaver) { save(it) } }, -//// value.youtubeAlbum?.let { with(YouTubeAlbumSaver) { save(it) } }, -// ) -//} +import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage +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.AlbumItem +import it.vfsfitvnm.vimusic.ui.views.AlbumItemPlaceholder +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.thumbnail +import it.vfsfitvnm.youtubemusic.Innertube +import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody +import it.vfsfitvnm.youtubemusic.requests.albumPage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) @ExperimentalAnimationApi @@ -99,21 +69,217 @@ import it.vfsfitvnm.vimusic.ui.screens.globalRoutes fun AlbumScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabChanged) = rememberSaveable { + mutableStateOf(0) + } + + val album by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(AlbumSaver), + ) { + Database + .album(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + val innertubeAlbum by produceSaveableState( + initialValue = null, + stateSaver = nullableSaver(InnertubePlaylistOrAlbumPageSaver), + tabIndex > 0 + ) { + if (value != null || (tabIndex == 0 && withContext(Dispatchers.IO) { Database.albumTimestamp(browseId) } != null)) return@produceSaveableState + + withContext(Dispatchers.IO) { + Innertube.albumPage(BrowseBody(browseId = browseId)) + }?.onSuccess { albumPage -> + value = albumPage + + query { + Database.upsert( + Album( + id = browseId, + title = albumPage.title, + thumbnailUrl = albumPage.thumbnail?.url, + year = albumPage.year, + authorsText = albumPage.authors?.joinToString("") { it.name ?: "" }, + shareUrl = albumPage.url, + timestamp = System.currentTimeMillis() + ), + albumPage + .songsPage + ?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { position, mediaItem -> + SongAlbumMap( + songId = mediaItem.mediaId, + albumId = browseId, + position = position + ) + } ?: emptyList() + ) + } + } + } + RouteHandler(listenToGlobalEmitter = true) { globalRoutes() host { + val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { textButton -> + if (album?.timestamp == null) { + HeaderPlaceholder( + modifier = Modifier + .shimmer() + ) + } else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = album?.title ?: "Unknown") { + textButton?.invoke() + + Spacer( + modifier = Modifier + .weight(1f) + ) + + Image( + painter = painterResource( + if (album?.bookmarkedAt == null) { + R.drawable.bookmark_outline + } else { + R.drawable.bookmark + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.accent), + modifier = Modifier + .clickable { + val bookmarkedAt = + if (album?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + album + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } + .padding(all = 4.dp) + .size(18.dp) + ) + + Image( + painter = painterResource(R.drawable.share_social), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + album?.shareUrl?.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) + ) + } + } + } + + val thumbnailContent: @Composable ColumnScope.() -> Unit = { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + if (album?.timestamp == null) { + Spacer( + modifier = Modifier + .shimmer() + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + .clip(CircleShape) + .fillMaxWidth() + .aspectRatio(1f) + .background(colorPalette.shimmer) + ) + } else { + BoxWithConstraints( + modifier = Modifier + .align(Alignment.CenterHorizontally) + ) { + val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth + val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px + + AsyncImage( + model = album?.thumbnailUrl?.thumbnail(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .padding(all = 16.dp) + .clip(thumbnailShape) + .size(thumbnailSizeDp) + ) + } + } + } + Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, - tabIndex = 0, - onTabChanged = { }, + tabIndex = tabIndex, + onTabChanged = onTabChanged, tabColumnContent = { Item -> - Item(0, "Overview", R.drawable.sparkles) + Item(0, "Songs", R.drawable.musical_notes) + Item(1, "Other versions", R.drawable.disc) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { - AlbumOverview(browseId = browseId) + when (currentTabIndex) { + 0 -> AlbumSongs( + browseId = browseId, + headerContent = headerContent, + thumbnailContent = thumbnailContent, + ) + 1 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemsPage( + stateSaver = InnertubeAlbumsPageSaver, + headerContent = headerContent, + itemsPageProvider = innertubeAlbum?.let { + ({ + Result.success( + Innertube.ItemsPage( + items = innertubeAlbum?.otherVersions, + continuation = null + ) + ) + }) + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { albumRoute(album.key) } + ) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + } + ) + } + } } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt new file mode 100644 index 0000000..2d0656e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/album/AlbumSongs.kt @@ -0,0 +1,143 @@ +package it.vfsfitvnm.vimusic.ui.screens.album + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +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.savers.DetailedSongListSaver +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton +import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton +import it.vfsfitvnm.vimusic.ui.components.themed.ShimmerHost +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.ui.views.SongItemPlaceholder +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.enqueue +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning +import it.vfsfitvnm.vimusic.utils.produceSaveableState +import it.vfsfitvnm.vimusic.utils.semiBold +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@Composable +fun AlbumSongs( + browseId: String, + headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + thumbnailContent: @Composable ColumnScope.() -> Unit, +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + val songs by produceSaveableState( + initialValue = emptyList(), + stateSaver = DetailedSongListSaver + ) { + Database + .albumSongs(browseId) + .flowOn(Dispatchers.IO) + .collect { value = it } + } + + Box { + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column { + headerContent { + SecondaryTextButton( + text = "Enqueue", + isEnabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem)) + } + ) + } + + thumbnailContent() + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + title = song.title, + authors = song.artistsText, + durationText = song.durationText, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + songs.map(DetailedSong::asMediaItem), + index + ) + }, + startContent = { + BasicText( + text = "${index + 1}", + style = typography.s.semiBold.center.color(colorPalette.textDisabled), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(Dimensions.thumbnails.song) + ) + }, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + } + ) + } + + if (songs.isEmpty()) { + item(key = "loading") { + ShimmerHost { + repeat(4) { + SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) + } + } + } + } + } + + PrimaryButton( + iconId = R.drawable.shuffle, + isEnabled = songs.isNotEmpty(), + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt index 2511cf7..0c83068 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistLocalSongsList.kt @@ -39,8 +39,8 @@ import kotlinx.coroutines.flow.flowOn @Composable fun ArtistLocalSongsList( browseId: String, - thumbnailContent: @Composable ColumnScope.() -> Unit, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + thumbnailContent: @Composable ColumnScope.() -> Unit, ) { val binder = LocalPlayerServiceBinder.current val (colorPalette) = LocalAppearance.current diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt index 0f81e3a..74e1dde 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/artist/ArtistScreen.kt @@ -44,7 +44,7 @@ import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.albumRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes -import it.vfsfitvnm.vimusic.ui.screens.searchresult.ArtistContent +import it.vfsfitvnm.vimusic.ui.screens.searchresult.ItemsPage import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.px @@ -97,7 +97,7 @@ fun ArtistScreen(browseId: String) { if (value != null || (tabIndex == 4 && withContext(Dispatchers.IO) { Database.artistTimestamp(browseId) } != null)) return@produceSaveableState withContext(Dispatchers.IO) { - Innertube.artistPage(browseId) + Innertube.artistPage(BrowseBody(browseId = browseId)) }?.onSuccess { artistPage -> value = artistPage @@ -252,7 +252,7 @@ fun ArtistScreen(browseId: String) { val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = InnertubeSongsPageSaver, headerContent = headerContent, itemsPageProvider = youtubeArtist?.let {({ continuation -> @@ -301,7 +301,7 @@ fun ArtistScreen(browseId: String) { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = InnertubeAlbumsPageSaver, headerContent = headerContent, itemsPageProvider = youtubeArtist?.let {({ continuation -> @@ -352,7 +352,7 @@ fun ArtistScreen(browseId: String) { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = InnertubeAlbumsPageSaver, headerContent = headerContent, itemsPageProvider = youtubeArtist?.let {({ continuation -> diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt index 021fd4d..115171a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemsPage.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext @ExperimentalAnimationApi @Composable -inline fun ArtistContent( +inline fun ItemsPage( stateSaver: Saver, List>, noinline itemsPageProvider: (suspend (String?) -> Result?>?)? = null, crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, 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 5ceb8cc..264efbc 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 @@ -93,7 +93,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = InnertubeSongsPageSaver, itemsPageProvider = { continuation -> if (continuation == null) { @@ -130,7 +130,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = InnertubeAlbumsPageSaver, itemsPageProvider = { continuation -> if (continuation == null) { @@ -170,7 +170,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val thumbnailSizeDp = 64.dp val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = innertubeItemsPageSaver(InnertubeArtistItemListSaver), itemsPageProvider = { continuation -> if (continuation == null) { @@ -209,7 +209,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val thumbnailHeightDp = 72.dp val thumbnailWidthDp = 128.dp - ArtistContent( + ItemsPage( stateSaver = innertubeItemsPageSaver(InnertubeVideoItemListSaver), itemsPageProvider = { continuation -> if (continuation == null) { @@ -250,7 +250,7 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px - ArtistContent( + ItemsPage( stateSaver = innertubeItemsPageSaver(InnertubePlaylistItemListSaver), itemsPageProvider = { continuation -> if (continuation == null) { diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt index 002f44b..6370b30 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt @@ -178,7 +178,8 @@ object Innertube { val year: String?, val thumbnail: Thumbnail?, val url: String?, - val songsPage: ItemsPage? + val songsPage: ItemsPage?, + val otherVersions: List? ) data class NextPage( diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt index ad51a67..e5ea54e 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/models/MusicCarouselShelfRenderer.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class MusicCarouselShelfRenderer( val header: Header?, - val contents: List, + val contents: List?, ) { @Serializable data class Content( diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt index 392346a..c0f1ccb 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/ArtistPage.kt @@ -13,9 +13,9 @@ import it.vfsfitvnm.youtubemusic.utils.findSectionByTitle import it.vfsfitvnm.youtubemusic.utils.from import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable -suspend fun Innertube.artistPage(browseId: String): Result? = runCatchingNonCancellable { +suspend fun Innertube.artistPage(body: BrowseBody): Result? = runCatchingNonCancellable { val response = client.post(browse) { - setBody(BrowseBody(browseId = browseId)) + setBody(body) mask("contents,header") }.body() diff --git a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt index 7f796d5..2c01ccb 100644 --- a/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt +++ b/innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/requests/PlaylistPage.kt @@ -6,6 +6,7 @@ import io.ktor.client.request.setBody import it.vfsfitvnm.youtubemusic.Innertube import it.vfsfitvnm.youtubemusic.models.BrowseResponse import it.vfsfitvnm.youtubemusic.models.ContinuationResponse +import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody @@ -15,14 +16,14 @@ import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable { val response = client.post(browse) { setBody(body) - mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat") + mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),musicCarouselShelfRenderer.contents.$musicTwoRowItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat") }.body() val musicDetailHeaderRenderer = response .header ?.musicDetailHeaderRenderer - val musicShelfRenderer = response + val sectionListRendererContents = response .contents ?.singleColumnBrowseResultsRenderer ?.tabs @@ -31,9 +32,15 @@ suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable ?.content ?.sectionListRenderer ?.contents + + val musicShelfRenderer = sectionListRendererContents ?.firstOrNull() ?.musicShelfRenderer + val musicCarouselShelfRenderer = sectionListRendererContents + ?.getOrNull(1) + ?.musicCarouselShelfRenderer + Innertube.PlaylistOrAlbumPage( title = musicDetailHeaderRenderer ?.title @@ -60,7 +67,11 @@ suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable ?.microformatDataRenderer ?.urlCanonical, songsPage = musicShelfRenderer - ?.toSongsPage() + ?.toSongsPage(), + otherVersions = musicCarouselShelfRenderer + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from) ) }