Tweak code
This commit is contained in:
parent
5f5a763675
commit
0ac516b39b
16 changed files with 699 additions and 1085 deletions
|
@ -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>>
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue