Tweak code

This commit is contained in:
vfsfitvnm 2022-10-03 12:52:24 +02:00
parent 5f5a763675
commit 0ac516b39b
16 changed files with 699 additions and 1085 deletions

View file

@ -152,6 +152,9 @@ interface Database {
@Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?>
@Query("SELECT timestamp FROM Artist WHERE id = :id")
fun artistTimestamp(id: String): Long?
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC")
fun artistsByNameDesc(): Flow<List<Artist>>

View file

@ -8,9 +8,9 @@ import androidx.room.PrimaryKey
@Entity
data class Artist(
@PrimaryKey val id: String,
val name: String?,
val thumbnailUrl: String?,
val info: String?,
val timestamp: Long?,
val name: String? = null,
val thumbnailUrl: String? = null,
val info: String? = null,
val timestamp: Long? = null,
val bookmarkedAt: Long? = null,
)

View file

@ -1,31 +1,4 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.Innertube
object InnertubeSongsPageSaver : Saver<Innertube.ItemsPage<Innertube.SongItem>, List<Any?>> {
override fun SaverScope.save(value: Innertube.ItemsPage<Innertube.SongItem>) = listOf(
value.items?.let {with(InnertubeSongItemListSaver) { save(it) } },
value.continuation
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = Innertube.ItemsPage(
items = (value[0] as List<List<Any?>>?)?.let(InnertubeSongItemListSaver::restore),
continuation = value[1] as String?
)
}
object InnertubeAlbumsPageSaver : Saver<Innertube.ItemsPage<Innertube.AlbumItem>, List<Any?>> {
override fun SaverScope.save(value: Innertube.ItemsPage<Innertube.AlbumItem>) = listOf(
value.items?.let {with(InnertubeAlbumItemListSaver) { save(it) } },
value.continuation
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = Innertube.ItemsPage(
items = (value[0] as List<List<Any?>>?)?.let(InnertubeAlbumItemListSaver::restore),
continuation = value[1] as String?
)
}
val InnertubeSongsPageSaver = innertubeItemsPageSaver(InnertubeSongItemListSaver)
val InnertubeAlbumsPageSaver = innertubeItemsPageSaver(InnertubeAlbumItemListSaver)

View file

@ -2,6 +2,7 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.Innertube
interface ListSaver<Original, Saveable : Any> : Saver<List<Original>, List<Saveable>> {
override fun SaverScope.save(value: List<Original>): List<Saveable>
@ -35,3 +36,17 @@ fun <Original, Saveable : Any> nullableSaver(saver: Saver<Original, Saveable>) =
override fun restore(value: Saveable): Original? =
saver.restore(value)
}
fun <Original : Innertube.Item> innertubeItemsPageSaver(saver: ListSaver<Original, List<Any?>>) =
object : Saver<Innertube.ItemsPage<Original>, List<Any?>> {
override fun SaverScope.save(value: Innertube.ItemsPage<Original>) = listOf(
value.items?.let { with(saver) { save(it) } },
value.continuation
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = Innertube.ItemsPage(
items = (value[0] as List<List<Any?>>?)?.let(saver::restore),
continuation = value[1] as String?
)
}

View file

@ -1,7 +1,6 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import android.annotation.SuppressLint
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibilityScope
@ -10,26 +9,14 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.with
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@SuppressLint("ModifierParameter")

View file

@ -0,0 +1,31 @@
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.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import com.valentinilk.shimmer.shimmer
@Composable
fun ShimmerHost(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier
.shimmer()
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(
listOf(Color.Black, Color.Transparent)
),
blendMode = BlendMode.DstIn
)
},
content = content
)
}

View file

@ -1,152 +0,0 @@
package it.vfsfitvnm.vimusic.ui.screens.artist
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
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.saveable.autoSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.savers.ListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.produceSaveableRelaunchableOneShotState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.Innertube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
inline fun <T : Innertube.Item> ArtistContent(
artist: Artist?,
youtubeArtistPage: Innertube.ArtistPage?,
isLoading: Boolean,
isError: Boolean,
stateSaver: ListSaver<T, List<Any?>>,
crossinline itemsPageProvider: suspend (String?) -> Result<Innertube.ItemsPage<T>?>?,
crossinline bookmarkIconContent: @Composable () -> Unit,
crossinline shareIconContent: @Composable () -> Unit,
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
noinline itemPlaceholderContent: @Composable () -> Unit,
) {
var items by rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(listOf())
}
var isLoadingItems by remember {
mutableStateOf(false)
}
var isErrorItems by remember {
mutableStateOf(false)
}
val (continuationState, fetch) = produceSaveableRelaunchableOneShotState(
initialValue = null,
stateSaver = autoSaver<String?>(),
youtubeArtistPage
) {
if (youtubeArtistPage == null) return@produceSaveableRelaunchableOneShotState
println("loading... $value")
isLoadingItems = true
withContext(Dispatchers.IO) {
itemsPageProvider(value)?.onSuccess { itemsPage ->
value = itemsPage?.continuation
itemsPage?.items?.let {
items = items.plus(it).distinctBy(Innertube.Item::key)
}
isErrorItems = false
isLoadingItems = false
}?.onFailure {
println("error (2): $it")
isErrorItems = true
isLoadingItems = false
}
}
}
val continuation by continuationState
when {
artist != null -> {
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0,
) {
Header(title = artist.name ?: "Unknown") {
bookmarkIconContent()
shareIconContent()
}
}
items(
items = items,
key = Innertube.Item::key,
itemContent = itemContent
)
if (isError || isErrorItems) {
item(key = "error") {
BasicText(
text = "An error has occurred",
style = LocalAppearance.current.typography.s.secondary.center,
modifier = Modifier
.padding(all = 16.dp)
)
}
} else {
item("loading") {
val hasMore = continuation != null
if (hasMore || items.isEmpty()) {
ShimmerHost {
repeat(if (hasMore) 3 else 8) {
itemPlaceholderContent()
}
}
// if (hasMore && items.isNotEmpty()) {
// println("loading again!")
// SideEffect(fetch)
// }
}
}
}
}
}
isError -> BasicText(
text = "An error has occurred",
style = LocalAppearance.current.typography.s.secondary.center,
modifier = Modifier
.padding(all = 16.dp)
)
isLoading -> ShimmerHost {
HeaderPlaceholder()
repeat(5) {
itemPlaceholderContent()
}
}
}
}

View file

@ -2,55 +2,36 @@ package it.vfsfitvnm.vimusic.ui.screens.artist
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
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.ColumnScope
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.CircleShape
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.unit.dp
import coil.compose.AsyncImage
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.Artist
import it.vfsfitvnm.vimusic.models.DetailedSong
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.savers.nullableSaver
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.components.themed.ShimmerHost
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.ui.views.SongItemPlaceholder
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.produceSaveableState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.thumbnail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
@ -58,18 +39,15 @@ import kotlinx.coroutines.flow.flowOn
@Composable
fun ArtistLocalSongsList(
browseId: String,
artist: Artist?,
isLoading: Boolean,
isError: Boolean,
bookmarkIconContent: @Composable () -> Unit,
shareIconContent: @Composable () -> Unit,
thumbnailContent: @Composable ColumnScope.() -> Unit,
headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit,
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val (colorPalette) = LocalAppearance.current
val songs by produceSaveableState(
initialValue = emptyList(),
stateSaver = DetailedSongListSaver
initialValue = null,
stateSaver = nullableSaver(DetailedSongListSaver)
) {
Database
.artistSongs(browseId)
@ -79,133 +57,70 @@ fun ArtistLocalSongsList(
val songThumbnailSizePx = Dimensions.thumbnails.song.px
BoxWithConstraints {
val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth
val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px
when {
artist != null -> {
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Column {
Header(title = artist.name ?: "Unknown") {
SecondaryTextButton(
text = "Enqueue",
isEnabled = songs.isNotEmpty(),
onClick = {
binder?.player?.enqueue(songs.map(DetailedSong::asMediaItem))
}
)
Spacer(
modifier = Modifier
.weight(1f)
)
bookmarkIconContent()
shareIconContent()
}
AsyncImage(
model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.size(thumbnailSizeDp)
)
}
}
itemsIndexed(
items = songs,
key = { _, song -> song.id }
) { index, song ->
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(DetailedSong::asMediaItem),
index
)
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
}
)
}
}
PrimaryButton(
iconId = R.drawable.shuffle,
isEnabled = songs.isNotEmpty(),
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
)
}
isError -> Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxSize()
Box {
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
BasicText(
text = "An error has occurred.",
style = typography.s.secondary.center,
modifier = Modifier
.align(Alignment.Center)
)
}
isLoading -> ShimmerHost {
HeaderPlaceholder()
Spacer(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.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 {
headerContent {
SecondaryTextButton(
text = "Enqueue",
isEnabled = !songs.isNullOrEmpty(),
onClick = {
binder?.player?.enqueue(songs!!.map(DetailedSong::asMediaItem))
}
)
Column {
TextPlaceholder()
TextPlaceholder()
}
}
thumbnailContent()
}
}
songs?.let { songs ->
itemsIndexed(
items = songs,
key = { _, song -> song.id }
) { index, song ->
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(DetailedSong::asMediaItem),
index
)
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
}
)
}
} ?: item(key = "loading") {
ShimmerHost {
repeat(4) {
SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song)
}
}
}
}
PrimaryButton(
iconId = R.drawable.shuffle,
isEnabled = !songs.isNullOrEmpty(),
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs!!.shuffled().map(DetailedSong::asMediaItem)
)
}
)
}
}

View file

@ -5,15 +5,15 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@ -26,14 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
@ -42,6 +35,7 @@ import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
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.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@ -56,23 +50,19 @@ import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.forcePlay
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.NavigationEndpoint
@ExperimentalAnimationApi
@Composable
fun ArtistOverview(
artist: Artist?,
youtubeArtistPage: Innertube.ArtistPage?,
isLoading: Boolean,
isError: Boolean,
onViewAllSongsClick: () -> Unit,
onViewAllAlbumsClick: () -> Unit,
onViewAllSinglesClick: () -> Unit,
onAlbumClick: (String) -> Unit,
bookmarkIconContent: @Composable () -> Unit,
shareIconContent: @Composable () -> Unit,
thumbnailContent: @Composable ColumnScope.() -> Unit,
headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit,
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
@ -86,10 +76,7 @@ fun ArtistOverview(
.padding(horizontal = 16.dp)
.padding(top = 24.dp, bottom = 8.dp)
BoxWithConstraints {
val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth
val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px
Box {
Column(
modifier = Modifier
.background(colorPalette.background0)
@ -97,223 +84,167 @@ fun ArtistOverview(
.verticalScroll(rememberScrollState())
.padding(LocalPlayerAwarePaddingValues.current)
) {
when {
artist != null -> {
Header(title = artist.name ?: "Unknown") {
youtubeArtistPage?.radioEndpoint?.let { radioEndpoint ->
SecondaryTextButton(
text = "Start radio",
onClick = {
binder?.stopRadio()
binder?.playRadio(radioEndpoint)
}
)
headerContent {
youtubeArtistPage?.radioEndpoint?.let { radioEndpoint ->
SecondaryTextButton(
text = "Start radio",
onClick = {
binder?.stopRadio()
binder?.playRadio(radioEndpoint)
}
)
}
}
Spacer(
modifier = Modifier
.weight(1f)
thumbnailContent()
if (youtubeArtistPage != null) {
youtubeArtistPage.songs?.let { songs ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Songs",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
bookmarkIconContent()
shareIconContent()
youtubeArtistPage.songsEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllSongsClick
),
)
}
}
AsyncImage(
model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.size(thumbnailSizeDp)
)
when {
youtubeArtistPage != null -> {
youtubeArtistPage.songs?.let { songs ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Songs",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtistPage.songsEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllSongsClick
),
)
}
}
songs.forEach { song ->
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
}
)
}
}
youtubeArtistPage.albums?.let { albums ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Albums",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtistPage.albumsEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllAlbumsClick
),
)
}
}
LazyRow(
modifier = Modifier
.fillMaxWidth()
) {
items(
items = albums,
key = Innertube.AlbumItem::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
youtubeArtistPage.singles?.let { singles ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Singles",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtistPage.singlesEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllSinglesClick
),
)
}
}
LazyRow(
modifier = Modifier
.fillMaxWidth()
) {
items(
items = singles,
key = Innertube.AlbumItem::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
}
isError -> ErrorText()
isLoading -> ShimmerHost {
TextPlaceholder(modifier = sectionTextModifier)
repeat(5) {
SongItemPlaceholder(
thumbnailSizeDp = songThumbnailSizeDp,
songs.forEach { song ->
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
}
)
}
}
repeat(2) {
TextPlaceholder(modifier = sectionTextModifier)
youtubeArtistPage.albums?.let { albums ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Albums",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
Row {
repeat(2) {
AlbumItemPlaceholder(
thumbnailSizeDp = albumThumbnailSizeDp,
alternative = true
)
}
}
}
youtubeArtistPage.albumsEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllAlbumsClick
),
)
}
}
LazyRow(
modifier = Modifier
.fillMaxWidth()
) {
items(
items = albums,
key = Innertube.AlbumItem::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
isError -> ErrorText()
isLoading -> ShimmerHost {
HeaderPlaceholder()
Spacer(
youtubeArtistPage.singles?.let { singles ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.size(thumbnailSizeDp)
.background(colorPalette.shimmer)
)
.fillMaxSize()
) {
BasicText(
text = "Singles",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtistPage.singlesEndpoint?.let {
BasicText(
text = "View all",
style = typography.xs.secondary,
modifier = sectionTextModifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onViewAllSinglesClick
),
)
}
}
LazyRow(
modifier = Modifier
.fillMaxWidth()
) {
items(
items = singles,
key = Innertube.AlbumItem::key
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
} else {
ShimmerHost {
TextPlaceholder(modifier = sectionTextModifier)
repeat(5) {
@ -349,33 +280,3 @@ fun ArtistOverview(
}
}
}
@Composable
fun ColumnScope.ErrorText() {
BasicText(
text = "An error has occurred",
style = LocalAppearance.current.typography.s.secondary.center,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
)
}
@Composable
fun ShimmerHost(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier
.shimmer()
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(
listOf(Color.Black, Color.Transparent)
),
blendMode = BlendMode.DstIn
)
},
content = content
)
}

View file

@ -3,22 +3,31 @@ package it.vfsfitvnm.vimusic.ui.screens.artist
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.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.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.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.LocalPlayerServiceBinder
@ -26,16 +35,20 @@ import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.PartialArtist
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.savers.ArtistSaver
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver
import it.vfsfitvnm.vimusic.savers.InnertubeArtistPageSaver
import it.vfsfitvnm.vimusic.savers.InnertubeSongItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver
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
import it.vfsfitvnm.vimusic.ui.screens.searchresult.ArtistContent
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.ui.views.SongItem
@ -43,9 +56,9 @@ import it.vfsfitvnm.vimusic.ui.views.SongItemPlaceholder
import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.produceSaveableLazyOneShotState
import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.vimusic.utils.rememberPreference
import it.vfsfitvnm.vimusic.utils.thumbnail
import it.vfsfitvnm.youtubemusic.Innertube
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
@ -53,7 +66,6 @@ import it.vfsfitvnm.youtubemusic.requests.artistPage
import it.vfsfitvnm.youtubemusic.requests.itemsPage
import it.vfsfitvnm.youtubemusic.utils.from
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
@ -61,51 +73,12 @@ import kotlinx.coroutines.withContext
@Composable
fun ArtistScreen(browseId: String) {
val saveableStateHolder = rememberSaveableStateHolder()
val (tabIndex, onTabIndexChanged) = rememberPreference(
artistScreenTabIndexKey,
defaultValue = 0
)
var isLoading by remember {
mutableStateOf(false)
}
var isError by remember {
mutableStateOf(false)
}
val youtubeArtist by produceSaveableLazyOneShotState(
initialValue = null,
stateSaver = nullableSaver(InnertubeArtistPageSaver)
) {
println("${System.currentTimeMillis()}, computing lazyEffect (youtubeArtistResult = ${value?.name})!")
isLoading = true
withContext(Dispatchers.IO) {
Innertube.artistPage(browseId)?.onSuccess { artistPage ->
value = artistPage
query {
Database.upsert(
PartialArtist(
id = browseId,
name = artistPage.name,
thumbnailUrl = artistPage.thumbnail?.url,
info = artistPage.description,
timestamp = System.currentTimeMillis()
)
)
}
isError = false
isLoading = false
}?.onFailure {
println("error (1): $it")
isError = true
isLoading = false
}
}
}
val artist by produceSaveableState(
initialValue = null,
stateSaver = nullableSaver(ArtistSaver),
@ -113,75 +86,140 @@ fun ArtistScreen(browseId: String) {
Database
.artist(browseId)
.flowOn(Dispatchers.IO)
.filter {
val hasToFetch = it?.timestamp == null
if (hasToFetch) {
youtubeArtist?.name
}
!hasToFetch
}
.collect { value = it }
}
val youtubeArtist by produceSaveableState(
initialValue = null,
stateSaver = nullableSaver(InnertubeArtistPageSaver),
tabIndex < 4
) {
if (value != null || (tabIndex == 4 && withContext(Dispatchers.IO) { Database.artistTimestamp(browseId) } != null)) return@produceSaveableState
withContext(Dispatchers.IO) {
Innertube.artistPage(browseId)
}?.onSuccess { artistPage ->
value = artistPage
query {
Database.upsert(
PartialArtist(
id = browseId,
name = artistPage.name,
thumbnailUrl = artistPage.thumbnail?.url,
info = artistPage.description,
timestamp = System.currentTimeMillis()
)
)
}
}
}
RouteHandler(listenToGlobalEmitter = true) {
globalRoutes()
host {
val bookmarkIconContent: @Composable () -> Unit = {
Image(
painter = painterResource(
if (artist?.bookmarkedAt == null) {
R.drawable.bookmark_outline
} else {
R.drawable.bookmark
}
),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.accent),
modifier = Modifier
.clickable {
val bookmarkedAt =
if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null
val thumbnailContent: @Composable ColumnScope.() -> Unit = {
if (artist?.timestamp == null) {
Spacer(
modifier = Modifier
.shimmer()
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.fillMaxWidth()
.aspectRatio(1f)
.background(LocalAppearance.current.colorPalette.shimmer)
)
} else {
BoxWithConstraints(
modifier = Modifier
.align(Alignment.CenterHorizontally)
) {
val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth
val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px
query {
artist
?.copy(bookmarkedAt = bookmarkedAt)
?.let(Database::update)
}
}
.padding(all = 4.dp)
.size(18.dp)
)
AsyncImage(
model = artist?.thumbnailUrl?.thumbnail(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.padding(all = 16.dp)
.clip(CircleShape)
.size(thumbnailSizeDp)
)
}
}
}
val shareIconContent: @Composable () -> Unit = {
val context = LocalContext.current
val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { textButton ->
if (artist?.timestamp == null) {
HeaderPlaceholder(
modifier = Modifier
.shimmer()
)
} else {
val context = LocalContext.current
Image(
painter = painterResource(R.drawable.share_social),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.text),
modifier = Modifier
.clickable {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
"https://music.youtube.com/channel/$browseId"
)
}
Header(title = artist?.name ?: "Unknown") {
textButton?.invoke()
context.startActivity(
Intent.createChooser(
sendIntent,
null
)
)
}
.padding(all = 4.dp)
.size(18.dp)
)
Spacer(
modifier = Modifier
.weight(1f)
)
Image(
painter = painterResource(
if (artist?.bookmarkedAt == null) {
R.drawable.bookmark_outline
} else {
R.drawable.bookmark
}
),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.accent),
modifier = Modifier
.clickable {
val bookmarkedAt =
if (artist?.bookmarkedAt == null) System.currentTimeMillis() else null
query {
artist
?.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(LocalAppearance.current.colorPalette.text),
modifier = Modifier
.clickable {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
"https://music.youtube.com/channel/$browseId"
)
}
context.startActivity(
Intent.createChooser(
sendIntent,
null
)
)
}
.padding(all = 4.dp)
.size(18.dp)
)
}
}
}
Scaffold(
@ -195,17 +233,14 @@ fun ArtistScreen(browseId: String) {
Item(2, "Albums", R.drawable.disc)
Item(3, "Singles", R.drawable.disc)
Item(4, "Library", R.drawable.library)
}
},
) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
when (currentTabIndex) {
0 -> ArtistOverview(
artist = artist,
youtubeArtistPage = youtubeArtist,
isLoading = isLoading,
isError = isError,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
thumbnailContent = thumbnailContent,
headerContent = headerContent,
onAlbumClick = { albumRoute(it) },
onViewAllSongsClick = { onTabIndexChanged(1) },
onViewAllAlbumsClick = { onTabIndexChanged(2) },
@ -218,14 +253,9 @@ fun ArtistScreen(browseId: String) {
val thumbnailSizePx = thumbnailSizeDp.px
ArtistContent(
artist = artist,
youtubeArtistPage = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = InnertubeSongItemListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsPageProvider = { continuation ->
stateSaver = InnertubeSongsPageSaver,
headerContent = headerContent,
itemsPageProvider = youtubeArtist?.let {({ continuation ->
continuation?.let {
Innertube.itemsPage(
body = ContinuationBody(continuation = continuation),
@ -233,14 +263,23 @@ fun ArtistScreen(browseId: String) {
)
} ?: youtubeArtist
?.songsEndpoint
?.browseId
?.let { browseId ->
?.takeIf { it.browseId != null }
?.let { endpoint ->
Innertube.itemsPage(
body = BrowseBody(browseId = browseId),
body = BrowseBody(
browseId = endpoint.browseId!!,
params = endpoint.params,
),
fromMusicResponsiveListItemRenderer = Innertube.SongItem::from,
)
}
},
?: Result.success(
Innertube.ItemsPage(
items = youtubeArtist?.songs,
continuation = null
)
)
})},
itemContent = { song ->
SongItem(
song = song,
@ -263,29 +302,33 @@ fun ArtistScreen(browseId: String) {
val thumbnailSizePx = thumbnailSizeDp.px
ArtistContent(
artist = artist,
youtubeArtistPage = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = InnertubeAlbumItemListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsPageProvider = { continuation ->
stateSaver = InnertubeAlbumsPageSaver,
headerContent = headerContent,
itemsPageProvider = youtubeArtist?.let {({ continuation ->
continuation?.let {
Innertube.itemsPage(
body = ContinuationBody(continuation = continuation),
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
)
} ?: youtubeArtist
?.songsEndpoint
?.browseId
?.let { browseId ->
?.albumsEndpoint
?.takeIf { it.browseId != null }
?.let { endpoint ->
Innertube.itemsPage(
body = BrowseBody(browseId = browseId),
body = BrowseBody(
browseId = endpoint.browseId!!,
params = endpoint.params,
),
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
)
}
},
?: Result.success(
Innertube.ItemsPage(
items = youtubeArtist?.albums,
continuation = null
)
)
})},
itemContent = { album ->
AlbumItem(
album = album,
@ -310,29 +353,33 @@ fun ArtistScreen(browseId: String) {
val thumbnailSizePx = thumbnailSizeDp.px
ArtistContent(
artist = artist,
youtubeArtistPage = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = InnertubeAlbumItemListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsPageProvider = { continuation ->
stateSaver = InnertubeAlbumsPageSaver,
headerContent = headerContent,
itemsPageProvider = youtubeArtist?.let {({ continuation ->
continuation?.let {
Innertube.itemsPage(
body = ContinuationBody(continuation = continuation),
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
)
} ?: youtubeArtist
?.songsEndpoint
?.browseId
?.let { browseId ->
?.singlesEndpoint
?.takeIf { it.browseId != null }
?.let { endpoint ->
Innertube.itemsPage(
body = BrowseBody(browseId = browseId),
body = BrowseBody(
browseId = endpoint.browseId!!,
params = endpoint.params,
),
fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from,
)
}
},
?: Result.success(
Innertube.ItemsPage(
items = youtubeArtist?.singles,
continuation = null
)
)
})},
itemContent = { album ->
AlbumItem(
album = album,
@ -354,11 +401,8 @@ fun ArtistScreen(browseId: String) {
4 -> ArtistLocalSongsList(
browseId = browseId,
artist = artist,
isLoading = isLoading,
isError = isError,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent
headerContent = headerContent,
thumbnailContent = thumbnailContent,
)
}
}

View file

@ -0,0 +1,90 @@
package it.vfsfitvnm.vimusic.ui.screens.searchresult
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.ui.components.themed.ShimmerHost
import it.vfsfitvnm.vimusic.utils.produceSaveableState
import it.vfsfitvnm.youtubemusic.Innertube
import it.vfsfitvnm.youtubemusic.utils.plus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
inline fun <T : Innertube.Item> ArtistContent(
stateSaver: Saver<Innertube.ItemsPage<T>, List<Any?>>,
noinline itemsPageProvider: (suspend (String?) -> Result<Innertube.ItemsPage<T>?>?)? = null,
crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit,
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
noinline itemPlaceholderContent: @Composable () -> Unit,
) {
val lazyListState = rememberLazyListState()
val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider)
val itemsPage by produceSaveableState(
initialValue = null,
stateSaver = nullableSaver(stateSaver),
lazyListState, updatedItemsPageProvider
) {
val currentItemsPageProvider = updatedItemsPageProvider ?: return@produceSaveableState
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } }
.collect { shouldLoadMore ->
if (!shouldLoadMore) return@collect
withContext(Dispatchers.IO) {
currentItemsPageProvider(value?.continuation)
}?.onSuccess {
if (it == null) {
if (value == null) {
value = Innertube.ItemsPage(null, null)
}
} else {
value += it
}
}
}
}
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0,
) {
headerContent(null)
}
items(
items = itemsPage?.items ?: emptyList(),
key = Innertube.Item::key,
itemContent = itemContent
)
if (!(itemsPage != null && itemsPage?.continuation == null)) {
item(key = "loading") {
ShimmerHost {
repeat(if (itemsPage?.items.isNullOrEmpty()) 8 else 3) {
itemPlaceholderContent()
}
}
}
}
}
}

View file

@ -1,172 +0,0 @@
package it.vfsfitvnm.vimusic.ui.screens.searchresult
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.savers.ListSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.produceSaveableRelaunchableOneShotState
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.Innertube
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody
import it.vfsfitvnm.youtubemusic.requests.searchPage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
inline fun <T : Innertube.Item> SearchResult(
query: String,
filter: String,
stateSaver: ListSaver<T, List<Any?>>,
noinline fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?,
crossinline onSearchAgain: () -> Unit,
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
noinline itemPlaceholderContent: @Composable BoxScope.() -> Unit,
) {
val (_, typography) = LocalAppearance.current
var items by rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(listOf())
}
val (continuationResultState, fetch) = produceSaveableRelaunchableOneShotState(
initialValue = null,
stateSaver = resultSaver(autoSaver<String?>())
) {
val token = value?.getOrNull()
value = null
value = withContext(Dispatchers.IO) {
if (token == null) {
Innertube.searchPage(
body = SearchBody(query = query, params = filter),
fromMusicShelfRendererContent = fromMusicShelfRendererContent
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = token),
fromMusicShelfRendererContent = fromMusicShelfRendererContent
)
}
}?.map { itemsPage ->
itemsPage?.items?.let {
items = items.plus(it).distinctBy(Innertube.Item::key)
}
itemsPage?.continuation
}
}
val continuationResult by continuationResultState
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0,
) {
Header(
title = query,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
onSearchAgain()
}
}
)
}
items(
items = items,
key = Innertube.Item::key,
itemContent = itemContent
)
continuationResult?.getOrNull()?.let {
if (items.isNotEmpty()) {
item {
SideEffect(fetch)
}
}
} ?: continuationResult?.exceptionOrNull()?.let {
item {
Box(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
fetch()
}
}
.fillMaxSize()
) {
BasicText(
text = "An error has occurred.\nTap to retry",
style = typography.s.medium.secondary.center,
modifier = Modifier
.align(Alignment.Center)
)
}
}
} ?: continuationResult?.let {
if (items.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "No results found.\nPlease try a different query or category",
style = typography.s.medium.secondary.center,
modifier = Modifier
.align(Alignment.Center)
)
}
}
}
} ?: item(key = "loading") {
Column(
modifier = Modifier
.shimmer()
) {
repeat(if (items.isEmpty()) 8 else 3) { index ->
Box(
modifier = Modifier
.alpha(1f - index * 0.125f),
content = itemPlaceholderContent
)
}
}
}
}
}

View file

@ -3,21 +3,25 @@ package it.vfsfitvnm.vimusic.ui.screens.searchresult
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubeAlbumsPageSaver
import it.vfsfitvnm.vimusic.savers.InnertubeArtistItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubePlaylistItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubeSongItemListSaver
import it.vfsfitvnm.vimusic.savers.InnertubeSongsPageSaver
import it.vfsfitvnm.vimusic.savers.InnertubeVideoItemListSaver
import it.vfsfitvnm.vimusic.savers.innertubeItemsPageSaver
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
import it.vfsfitvnm.vimusic.ui.screens.albumRoute
import it.vfsfitvnm.vimusic.ui.screens.artistRoute
@ -40,6 +44,9 @@ import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.vimusic.utils.rememberPreference
import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey
import it.vfsfitvnm.youtubemusic.Innertube
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody
import it.vfsfitvnm.youtubemusic.requests.searchPage
import it.vfsfitvnm.youtubemusic.utils.from
@ExperimentalFoundationApi
@ -53,6 +60,18 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
globalRoutes()
host {
val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = {
Header(
title = query,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
onSearchAgain()
}
}
)
}
Scaffold(
topIconButtonId = R.drawable.chevron_back,
onTopIconButtonClick = pop,
@ -67,16 +86,6 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
Item(5, "Featured", R.drawable.playlist)
}
) { tabIndex ->
val searchFilter = when (tabIndex) {
0 -> Innertube.SearchFilter.Song
1 -> Innertube.SearchFilter.Album
2 -> Innertube.SearchFilter.Artist
3 -> Innertube.SearchFilter.Video
4 -> Innertube.SearchFilter.CommunityPlaylist
5 -> Innertube.SearchFilter.FeaturedPlaylist
else -> error("unreachable")
}.value
saveableStateHolder.SaveableStateProvider(tabIndex) {
when (tabIndex) {
0 -> {
@ -84,12 +93,22 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailSizeDp = Dimensions.thumbnails.song
val thumbnailSizePx = thumbnailSizeDp.px
SearchResult(
query = query,
filter = searchFilter,
onSearchAgain = onSearchAgain,
stateSaver = InnertubeSongItemListSaver,
fromMusicShelfRendererContent = Innertube.SongItem.Companion::from,
ArtistContent(
stateSaver = InnertubeSongsPageSaver,
itemsPageProvider = { continuation ->
if (continuation == null) {
Innertube.searchPage(
body = SearchBody(query = query, params = Innertube.SearchFilter.Song.value),
fromMusicShelfRendererContent = Innertube.SongItem.Companion::from
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = continuation),
fromMusicShelfRendererContent = Innertube.SongItem.Companion::from
)
}
},
headerContent = headerContent,
itemContent = { song ->
SongItem(
song = song,
@ -111,12 +130,22 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
SearchResult(
query = query,
filter = searchFilter,
stateSaver = InnertubeAlbumItemListSaver,
onSearchAgain = onSearchAgain,
fromMusicShelfRendererContent = Innertube.AlbumItem.Companion::from,
ArtistContent(
stateSaver = InnertubeAlbumsPageSaver,
itemsPageProvider = { continuation ->
if (continuation == null) {
Innertube.searchPage(
body = SearchBody(query = query, params = Innertube.SearchFilter.Album.value),
fromMusicShelfRendererContent = Innertube.AlbumItem::from
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = continuation),
fromMusicShelfRendererContent = Innertube.AlbumItem::from
)
}
},
headerContent = headerContent,
itemContent = { album ->
AlbumItem(
album = album,
@ -141,12 +170,22 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailSizeDp = 64.dp
val thumbnailSizePx = thumbnailSizeDp.px
SearchResult(
query = query,
filter = searchFilter,
stateSaver = InnertubeArtistItemListSaver,
onSearchAgain = onSearchAgain,
fromMusicShelfRendererContent = Innertube.ArtistItem.Companion::from,
ArtistContent(
stateSaver = innertubeItemsPageSaver(InnertubeArtistItemListSaver),
itemsPageProvider = { continuation ->
if (continuation == null) {
Innertube.searchPage(
body = SearchBody(query = query, params = Innertube.SearchFilter.Artist.value),
fromMusicShelfRendererContent = Innertube.ArtistItem::from
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = continuation),
fromMusicShelfRendererContent = Innertube.ArtistItem::from
)
}
},
headerContent = headerContent,
itemContent = { artist ->
ArtistItem(
artist = artist,
@ -170,12 +209,22 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailHeightDp = 72.dp
val thumbnailWidthDp = 128.dp
SearchResult(
query = query,
filter = searchFilter,
stateSaver = InnertubeVideoItemListSaver,
onSearchAgain = onSearchAgain,
fromMusicShelfRendererContent = Innertube.VideoItem.Companion::from,
ArtistContent(
stateSaver = innertubeItemsPageSaver(InnertubeVideoItemListSaver),
itemsPageProvider = { continuation ->
if (continuation == null) {
Innertube.searchPage(
body = SearchBody(query = query, params = Innertube.SearchFilter.Video.value),
fromMusicShelfRendererContent = Innertube.VideoItem::from
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = continuation),
fromMusicShelfRendererContent = Innertube.VideoItem::from
)
}
},
headerContent = headerContent,
itemContent = { video ->
VideoItem(
video = video,
@ -201,12 +250,28 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) {
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
SearchResult(
query = query,
filter = searchFilter,
stateSaver = InnertubePlaylistItemListSaver,
onSearchAgain = onSearchAgain,
fromMusicShelfRendererContent = Innertube.PlaylistItem.Companion::from,
ArtistContent(
stateSaver = innertubeItemsPageSaver(InnertubePlaylistItemListSaver),
itemsPageProvider = { continuation ->
if (continuation == null) {
val filter = if (tabIndex == 4) {
Innertube.SearchFilter.CommunityPlaylist
} else {
Innertube.SearchFilter.FeaturedPlaylist
}
Innertube.searchPage(
body = SearchBody(query = query, params = filter.value),
fromMusicShelfRendererContent = Innertube.PlaylistItem::from
)
} else {
Innertube.searchPage(
body = ContinuationBody(continuation = continuation),
fromMusicShelfRendererContent = Innertube.PlaylistItem::from
)
}
},
headerContent = headerContent,
itemContent = { playlist ->
PlaylistItem(
playlist = playlist,

View file

@ -323,12 +323,14 @@ fun AlbumItem(
)
if (!alternative) {
BasicText(
text = album.authors?.joinToString("") { it.name ?: "" } ?: "",
style = typography.xs.semiBold.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
album.authors?.joinToString("") { it.name ?: "" }?.let { authorsText ->
BasicText(
text = authorsText,
style = typography.xs.semiBold.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
BasicText(

View file

@ -6,17 +6,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlin.coroutines.CoroutineContext
import kotlin.experimental.ExperimentalTypeInference
import kotlin.reflect.KProperty
import kotlinx.coroutines.suspendCancellableCoroutine
@Composable
@ -122,97 +119,6 @@ fun <T> produceSaveableState(
return result
}
@Composable
fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> Unit> {
val result = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue)
}
var produced by rememberSaveable {
mutableStateOf(false)
}
val relaunchableEffect = relaunchableEffect(Unit) {
if (!produced) {
ProduceSaveableStateScope(result, coroutineContext).producer()
produced = true
}
}
return result to {
produced = false
relaunchableEffect()
}
}
@Composable
fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
key1: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> Unit> {
val result = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue)
}
var produced by rememberSaveable(key1) {
mutableStateOf(false)
}
val relaunchableEffect = relaunchableEffect(key1) {
if (!produced) {
ProduceSaveableStateScope(result, coroutineContext).producer()
produced = true
}
}
return result to {
produced = false
relaunchableEffect()
}
}
@Composable
fun <T> produceSaveableLazyOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val state = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue)
}
var produced by rememberSaveable {
mutableStateOf(false)
}
val lazyEffect = lazyEffect(Unit) {
if (!produced) {
ProduceSaveableStateScope(state, coroutineContext).producer()
produced = true
}
}
val delegate = remember {
object : State<T> {
override val value: T
get() {
if (!produced) {
lazyEffect()
}
return state.value
}
}
}
return delegate
}
private class ProduceSaveableStateScope<T>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext

View file

@ -1,24 +1,25 @@
package it.vfsfitvnm.youtubemusic.utils
import io.ktor.utils.io.CancellationException
import it.vfsfitvnm.youtubemusic.Innertube
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
return contents?.find { content ->
val title = content
.musicCarouselShelfRenderer
?.header
?.musicCarouselShelfBasicHeaderRenderer
val title = content
.musicCarouselShelfRenderer
?.header
?.musicCarouselShelfBasicHeaderRenderer
?.title
?: content
.musicShelfRenderer
?.title
?: content
.musicShelfRenderer
?.title
title
?.runs
?.firstOrNull()
?.text == text
}
title
?.runs
?.firstOrNull()
?.text == text
}
}
internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? {
@ -31,14 +32,19 @@ internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionLi
?.runs
?.firstOrNull()
?.text == text
}
}
}
internal inline fun <R> runCatchingNonCancellable(block: () -> R): Result<R>? {
return Result.success(block())
// val result = runCatching(block)
// return when (val ex = result.exceptionOrNull()) {
// is CancellationException -> null
// else -> result
// }
val result = runCatching(block)
return when (result.exceptionOrNull()) {
is CancellationException -> null
else -> result
}
}
infix operator fun <T : Innertube.Item> Innertube.ItemsPage<T>?.plus(other: Innertube.ItemsPage<T>) =
other.copy(
items = this?.items?.plus(other.items ?: emptyList())?.distinctBy(Innertube.Item::key)
?: other.items
)