Add "Other versions" tab in AlbumScreen

This commit is contained in:
vfsfitvnm 2022-10-03 15:20:41 +02:00
parent 663ec33e3b
commit d0e9c7e6b9
15 changed files with 440 additions and 439 deletions

View file

@ -183,6 +183,9 @@ interface Database {
@Query("SELECT * FROM Album WHERE id = :id")
fun album(id: String): Flow<Album?>
@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

View file

@ -28,6 +28,4 @@ object AlbumSaver : Saver<Album, List<Any?>> {
)
}
val AlbumResultSaver = resultSaver(AlbumSaver)
val AlbumListSaver = listSaver(AlbumSaver)

View file

@ -7,11 +7,12 @@ import it.vfsfitvnm.youtubemusic.Innertube
object InnertubePlaylistOrAlbumPageSaver : Saver<Innertube.PlaylistOrAlbumPage, List<Any?>> {
override fun SaverScope.save(value: Innertube.PlaylistOrAlbumPage): List<Any?> = 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<Innertube.PlaylistOrAlbumPage,
thumbnail = (value[3] as List<Any?>?)?.let(InnertubeThumbnailSaver::restore),
url = value[4] as String?,
songsPage = (value[5] as List<Any?>?)?.let(InnertubeSongsPageSaver::restore),
otherVersions = (value[6] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
)
}

View file

@ -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)

View file

@ -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()
}
}
}
}
}
}

View file

@ -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<NavigationEndpoint.Endpoint.Browse>::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<AlbumScreenState, List<Any?>> {
// override fun restore(value: List<Any?>) = AlbumScreenState(
// initialIsLoading = value[0] as Boolean,
// initialError = value[1] as Throwable?,
// initialAlbum = (value[1] as List<Any?>?)?.let(AlbumSaver::restore),
// )
//
// override fun SaverScope.save(value: AlbumScreenState): List<Any?> =
// 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)
}
)
}
}
}
}
}

View file

@ -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)
)
}
)
}
}

View file

@ -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

View file

@ -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 ->

View file

@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
inline fun <T : Innertube.Item> ArtistContent(
inline fun <T : Innertube.Item> ItemsPage(
stateSaver: Saver<Innertube.ItemsPage<T>, List<Any?>>,
noinline itemsPageProvider: (suspend (String?) -> Result<Innertube.ItemsPage<T>?>?)? = null,
crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit,

View file

@ -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) {

View file

@ -178,7 +178,8 @@ object Innertube {
val year: String?,
val thumbnail: Thumbnail?,
val url: String?,
val songsPage: ItemsPage<SongItem>?
val songsPage: ItemsPage<SongItem>?,
val otherVersions: List<AlbumItem>?
)
data class NextPage(

View file

@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header?,
val contents: List<Content>,
val contents: List<Content>?,
) {
@Serializable
data class Content(

View file

@ -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<Innertube.ArtistPage>? = runCatchingNonCancellable {
suspend fun Innertube.artistPage(body: BrowseBody): Result<Innertube.ArtistPage>? = runCatchingNonCancellable {
val response = client.post(browse) {
setBody(BrowseBody(browseId = browseId))
setBody(body)
mask("contents,header")
}.body<BrowseResponse>()

View file

@ -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<BrowseResponse>()
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)
)
}