Redesign ArtistScreen (#123, #172)

This commit is contained in:
vfsfitvnm
2022-09-30 19:27:34 +02:00
parent cfd369266e
commit 4bc3671be1
25 changed files with 2107 additions and 381 deletions

View File

@@ -10,6 +10,7 @@ import androidx.media3.common.MediaItem
import androidx.room.AutoMigration
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@@ -39,6 +40,7 @@ import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.models.DetailedSongWithContentLength
import it.vfsfitvnm.vimusic.models.Event
import it.vfsfitvnm.vimusic.models.Format
import it.vfsfitvnm.vimusic.models.PartialArtist
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.PlaylistPreview
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
@@ -150,16 +152,16 @@ interface Database {
@Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?>
@Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY name DESC")
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name DESC")
fun artistsByNameDesc(): Flow<List<Artist>>
@Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY name ASC")
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY name ASC")
fun artistsByNameAsc(): Flow<List<Artist>>
@Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY ROWID DESC")
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID DESC")
fun artistsByRowIdDesc(): Flow<List<Artist>>
@Query("SELECT * FROM Artist WHERE timestamp IS NOT NULL ORDER BY ROWID ASC")
@Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY ROWID ASC")
fun artistsByRowIdAsc(): Flow<List<Artist>>
fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow<List<Artist>> {
@@ -378,7 +380,7 @@ interface Database {
name = artistName,
thumbnailUrl = null,
info = null,
timestamp = null,
timestamp = null
).also(::insert)
}
}
@@ -419,6 +421,9 @@ interface Database {
@Upsert
fun upsert(artist: Artist)
@Upsert(Artist::class)
fun upsert(artist: PartialArtist)
@Delete
fun delete(searchQuery: SearchQuery)
@@ -449,7 +454,7 @@ interface Database {
views = [
SortedSongPlaylistMap::class
],
version = 20,
version = 21,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
@@ -468,6 +473,7 @@ interface Database {
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19),
AutoMigration(from = 19, to = 20),
AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class),
],
)
@TypeConverters(Converters::class)
@@ -601,6 +607,14 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
it.execSQL("ALTER TABLE Song_new RENAME TO Song;")
}
}
@DeleteColumn.Entries(
DeleteColumn("Artist", "shuffleVideoId"),
DeleteColumn("Artist", "shufflePlaylistId"),
DeleteColumn("Artist", "radioVideoId"),
DeleteColumn("Artist", "radioPlaylistId"),
)
class From20To21Migration : AutoMigrationSpec
}
@TypeConverters

View File

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

View File

@@ -0,0 +1,9 @@
package it.vfsfitvnm.vimusic.models
data class PartialArtist(
val id: String,
val name: String?,
val thumbnailUrl: String?,
val info: String?,
val timestamp: Long? = null,
)

View File

@@ -3,7 +3,6 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.Playlist
object ArtistSaver : Saver<Artist, List<Any?>> {
override fun SaverScope.save(value: Artist): List<Any?> = listOf(
@@ -11,10 +10,6 @@ object ArtistSaver : Saver<Artist, List<Any?>> {
value.name,
value.thumbnailUrl,
value.info,
value.shuffleVideoId,
value.shufflePlaylistId,
value.radioVideoId,
value.radioPlaylistId,
value.timestamp,
value.bookmarkedAt,
)
@@ -24,11 +19,7 @@ object ArtistSaver : Saver<Artist, List<Any?>> {
name = value[1] as String,
thumbnailUrl = value[2] as String?,
info = value[3] as String?,
shuffleVideoId = value[4] as String?,
shufflePlaylistId = value[5] as String?,
radioVideoId = value[6] as String?,
radioPlaylistId = value[7] as String?,
timestamp = value[8] as Long?,
bookmarkedAt = value[9] as Long?,
timestamp = value[4] as Long?,
bookmarkedAt = value[5] as Long?,
)
}

View File

@@ -0,0 +1,36 @@
package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeArtistPageSaver : Saver<YouTube.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Artist): List<Any?> = listOf(
value.name,
value.description,
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } },
value.shuffleEndpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } },
value.radioEndpoint?.let { with(YouTubeWatchEndpointSaver) { save(it) } },
value.songs?.let { with(YouTubeSongListSaver) { save(it) } },
value.songsEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } },
value.albums?.let { with(YouTubeAlbumListSaver) { save(it) } },
value.albumsEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } },
value.singles?.let { with(YouTubeAlbumListSaver) { save(it) } },
value.singlesEndpoint?.let { with(YouTubeBrowseEndpointSaver) { save(it) } },
)
@Suppress("UNCHECKED_CAST")
override fun restore(value: List<Any?>) = YouTube.Artist(
name = value[0] as String?,
description = value[1] as String?,
thumbnail = (value[2] as List<Any?>?)?.let(YouTubeThumbnailSaver::restore),
shuffleEndpoint = (value[3] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore),
radioEndpoint = (value[4] as List<Any?>?)?.let(YouTubeWatchEndpointSaver::restore),
songs = (value[5] as List<List<Any?>>?)?.let(YouTubeSongListSaver::restore),
songsEndpoint = (value[6] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
albums = (value[7] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
albumsEndpoint = (value[8] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
singles = (value[9] as List<List<Any?>>?)?.let(YouTubeAlbumListSaver::restore),
singlesEndpoint = (value[10] as List<Any?>?)?.let(YouTubeBrowseEndpointSaver::restore),
)
}

View File

@@ -2,13 +2,14 @@ package it.vfsfitvnm.vimusic.savers
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import it.vfsfitvnm.vimusic.savers.YouTubeThumbnailSaver.save
import it.vfsfitvnm.youtubemusic.YouTube
object YouTubeArtistSaver : Saver<YouTube.Item.Artist, List<Any?>> {
override fun SaverScope.save(value: YouTube.Item.Artist): List<Any?> = listOf(
value.info?.let { with(YouTubeBrowseInfoSaver) { save(it) } },
value.subscribersCountText,
with(YouTubeThumbnailSaver) { value.thumbnail?.let { save(it) } }
value.thumbnail?.let { with(YouTubeThumbnailSaver) { save(it) } }
)
override fun restore(value: List<Any?>) = YouTube.Item.Artist(

View File

@@ -0,0 +1,31 @@
package it.vfsfitvnm.vimusic.ui.components
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

@@ -0,0 +1,155 @@
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.SideEffect
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.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
inline fun <T : YouTube.Item> ArtistContent(
artist: Artist?,
youtubeArtist: YouTube.Artist?,
isLoading: Boolean,
isError: Boolean,
stateSaver: ListSaver<T, List<Any?>>,
crossinline itemsProvider: suspend (String?) -> Result<Pair<String?, List<T>?>>?,
crossinline bookmarkIconContent: @Composable () -> Unit,
crossinline shareIconContent: @Composable () -> Unit,
crossinline itemContent: @Composable LazyItemScope.(T) -> Unit,
noinline itemShimmer: @Composable () -> Unit,
) {
val (_, typography) = LocalAppearance.current
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?>(),
youtubeArtist
) {
if (youtubeArtist == null) return@produceSaveableRelaunchableOneShotState
println("loading... $value")
isLoadingItems = true
withContext(Dispatchers.IO) {
itemsProvider(value)?.onSuccess { (continuation, newItems) ->
value = continuation
newItems?.let {
items = items.plus(it).distinctBy(YouTube.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 = YouTube.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) {
itemShimmer()
}
}
// 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) {
itemShimmer()
}
}
}
}

View File

@@ -0,0 +1,211 @@
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.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.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.styling.shimmer
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.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
@ExperimentalAnimationApi
@Composable
fun ArtistLocalSongsList(
browseId: String,
artist: Artist?,
isLoading: Boolean,
isError: Boolean,
bookmarkIconContent: @Composable () -> Unit,
shareIconContent: @Composable () -> Unit,
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val songs by produceSaveableState(
initialValue = emptyList(),
stateSaver = DetailedSongListSaver
) {
Database
.artistSongs(browseId)
.flowOn(Dispatchers.IO)
.collect { value = it }
}
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()
) {
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 {
TextPlaceholder()
TextPlaceholder()
}
}
}
}
}
}
}

View File

@@ -1,76 +1,373 @@
package it.vfsfitvnm.vimusic.ui.screens.artist
import android.content.Intent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
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.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
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 androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.ui.styling.shimmer
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.ui.views.AlternativeAlbumItem
import it.vfsfitvnm.vimusic.ui.views.AlternativeAlbumItemPlaceholder
import it.vfsfitvnm.vimusic.ui.views.SmallSongItem
import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.medium
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.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@Composable
fun ArtistOverview(
browseId: String,
artist: Artist?,
youtubeArtist: YouTube.Artist?,
isLoading: Boolean,
isError: Boolean,
onViewAllSongsClick: () -> Unit,
onViewAllAlbumsClick: () -> Unit,
onViewAllSinglesClick: () -> Unit,
onAlbumClick: (String) -> Unit,
bookmarkIconContent: @Composable () -> Unit,
shareIconContent: @Composable () -> Unit,
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val context = LocalContext.current
val songThumbnailSizeDp = Dimensions.thumbnails.song
val songThumbnailSizePx = songThumbnailSizeDp.px
val albumThumbnailSizeDp = 108.dp
val albumThumbnailSizePx = albumThumbnailSizeDp.px
val sectionTextModifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 24.dp, bottom = 8.dp)
BoxWithConstraints {
val thumbnailSizeDp = maxWidth - Dimensions.navigationRailWidth
val thumbnailSizePx = (thumbnailSizeDp - 32.dp).px
Column(
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(LocalPlayerAwarePaddingValues.current)
) {
when {
artist != null -> {
Header(title = artist.name ?: "Unknown") {
youtubeArtist?.radioEndpoint?.let { radioEndpoint ->
SecondaryTextButton(
text = "Start radio",
onClick = {
binder?.stopRadio()
binder?.playRadio(radioEndpoint)
}
)
}
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)
)
when {
youtubeArtist != null -> {
youtubeArtist.songs?.let { songs ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Songs",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtist.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 ->
SmallSongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
}
)
}
}
youtubeArtist.albums?.let { albums ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Albums",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtist.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 = YouTube.Item.Album::key
) { album ->
AlternativeAlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
youtubeArtist.singles?.let { singles ->
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "Singles",
style = typography.m.semiBold,
modifier = sectionTextModifier
)
youtubeArtist.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 = YouTube.Item.Album::key
) { album ->
AlternativeAlbumItem(
album = album,
thumbnailSizePx = albumThumbnailSizePx,
thumbnailSizeDp = albumThumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { onAlbumClick(album.key) }
)
)
}
}
}
}
isError -> ErrorText()
isLoading -> ShimmerHost {
TextPlaceholder(modifier = sectionTextModifier)
repeat(5) {
SmallSongItemShimmer(
thumbnailSizeDp = songThumbnailSizeDp,
)
}
repeat(2) {
TextPlaceholder(modifier = sectionTextModifier)
Row {
repeat(2) {
AlternativeAlbumItemPlaceholder(thumbnailSizeDp = albumThumbnailSizeDp)
}
}
}
}
}
}
isError -> ErrorText()
isLoading -> ShimmerHost {
HeaderPlaceholder()
Spacer(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(all = 16.dp)
.clip(CircleShape)
.size(thumbnailSizeDp)
.background(colorPalette.shimmer)
)
TextPlaceholder(modifier = sectionTextModifier)
repeat(5) {
SmallSongItemShimmer(
thumbnailSizeDp = songThumbnailSizeDp,
)
}
repeat(2) {
TextPlaceholder(modifier = sectionTextModifier)
Row {
repeat(2) {
AlternativeAlbumItemPlaceholder(thumbnailSizeDp = albumThumbnailSizeDp)
}
}
}
}
}
}
youtubeArtist?.shuffleEndpoint?.let { shuffleEndpoint ->
PrimaryButton(
iconId = R.drawable.shuffle,
onClick = {
binder?.stopRadio()
binder?.playRadio(shuffleEndpoint)
}
)
}
}
}
@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

@@ -1,87 +1,182 @@
package it.vfsfitvnm.vimusic.ui.screens.artist
import android.content.Intent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Alignment
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.models.PartialArtist
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.savers.ArtistSaver
import it.vfsfitvnm.vimusic.savers.YouTubeAlbumListSaver
import it.vfsfitvnm.vimusic.savers.YouTubeArtistPageSaver
import it.vfsfitvnm.vimusic.savers.YouTubeSongListSaver
import it.vfsfitvnm.vimusic.savers.nullableSaver
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.SearchResult
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.views.SongItem
import it.vfsfitvnm.vimusic.ui.views.AlbumItem
import it.vfsfitvnm.vimusic.ui.views.AlbumItemShimmer
import it.vfsfitvnm.vimusic.ui.views.SmallSongItem
import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.utils.artistScreenTabIndexKey
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.thumbnail
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.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@ExperimentalAnimationApi
@Composable
fun ArtistScreen(browseId: String) {
val saveableStateHolder = rememberSaveableStateHolder()
val (tabIndex, onTabIndexChanged) = rememberSaveable {
mutableStateOf(0)
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(YouTubeArtistPageSaver)
) {
println("${System.currentTimeMillis()}, computing lazyEffect (youtubeArtistResult = ${value?.name})!")
isLoading = true
withContext(Dispatchers.IO) {
YouTube.artist(browseId)?.onSuccess { youtubeArtist ->
value = youtubeArtist
query {
Database.upsert(
PartialArtist(
id = browseId,
name = youtubeArtist.name,
thumbnailUrl = youtubeArtist.thumbnail?.url,
info = youtubeArtist.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),
) {
Database
.artist(browseId)
.flowOn(Dispatchers.IO)
.filter {
val hasToFetch = it?.timestamp == null
if (hasToFetch) {
youtubeArtist?.name
}
!hasToFetch
}
.collect { value = it }
}
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
query {
artist?.copy(bookmarkedAt = bookmarkedAt)?.let(Database::update)
}
}
.padding(all = 4.dp)
.size(18.dp)
)
}
val shareIconContent: @Composable () -> Unit = {
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"
)
}
context.startActivity(
Intent.createChooser(
sendIntent,
null
)
)
}
.padding(all = 4.dp)
.size(18.dp)
)
}
Scaffold(
topIconButtonId = R.drawable.chevron_back,
onTopIconButtonClick = pop,
@@ -92,273 +187,151 @@ fun ArtistScreen(browseId: String) {
Item(1, "Songs", R.drawable.musical_notes)
Item(2, "Albums", R.drawable.disc)
Item(3, "Singles", R.drawable.disc)
Item(4, "Library", R.drawable.library)
}
) { currentTabIndex ->
) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
ArtistOverview(browseId = browseId)
}
}
}
}
}
@ExperimentalAnimationApi
@Composable
fun ArtistScreen2(browseId: String) {
val lazyListState = rememberLazyListState()
RouteHandler(listenToGlobalEmitter = true) {
globalRoutes()
host {
val binder = LocalPlayerServiceBinder.current
val (colorPalette, typography) = LocalAppearance.current
val artistResult by remember(browseId) {
Database.artist(browseId).map { artist ->
artist
?.takeIf { artist.timestamp != null }
?.let(Result.Companion::success)
?: fetchArtist(browseId)
}.distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
val songThumbnailSizePx = Dimensions.thumbnails.song.px
val songs by remember(browseId) {
Database.artistSongs(browseId)
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item {
}
item {
artistResult?.getOrNull()?.let { artist ->
AsyncImage(
model = artist.thumbnailUrl?.thumbnail(Dimensions.thumbnails.artist.px),
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.size(Dimensions.thumbnails.artist)
when (currentTabIndex) {
0 -> ArtistOverview(
artist = artist,
youtubeArtist = youtubeArtist,
isLoading = isLoading,
isError = isError,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
onAlbumClick = { albumRoute(it) },
onViewAllSongsClick = { onTabIndexChanged(1) },
onViewAllAlbumsClick = { onTabIndexChanged(2) },
onViewAllSinglesClick = { onTabIndexChanged(3) },
)
1 -> {
val binder = LocalPlayerServiceBinder.current
val thumbnailSizeDp = Dimensions.thumbnails.song
val thumbnailSizePx = thumbnailSizeDp.px
BasicText(
text = artist.name,
style = typography.l.semiBold,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.playRadio(
NavigationEndpoint.Endpoint.Watch(
videoId = artist.shuffleVideoId,
playlistId = artist.shufflePlaylistId
)
)
query {
runBlocking {
fetchArtist(browseId)
ArtistContent(
artist = artist,
youtubeArtist = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = YouTubeSongListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsProvider = { continuation ->
youtubeArtist
?.songsEndpoint
?.browseId
?.let { browseId ->
YouTube.items(browseId, continuation, YouTube.Item.Song::from)?.map { result ->
result?.continuation to result?.items
}
}
}
.padding(all = 8.dp)
.size(20.dp)
},
itemContent = { song ->
SmallSongItem(
song = song,
thumbnailSizePx = thumbnailSizePx,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlay(song.asMediaItem)
binder?.setupRadio(song.info?.endpoint)
}
)
},
itemShimmer = {
SmallSongItemShimmer(thumbnailSizeDp = thumbnailSizeDp)
}
)
}
2 -> {
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
Image(
painter = painterResource(R.drawable.radio),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
binder?.playRadio(
NavigationEndpoint.Endpoint.Watch(
videoId = artist.radioVideoId
?: artist.shuffleVideoId,
playlistId = artist.radioPlaylistId
)
)
query {
runBlocking {
fetchArtist(browseId)
ArtistContent(
artist = artist,
youtubeArtist = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = YouTubeAlbumListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsProvider = {
youtubeArtist
?.albumsEndpoint
?.let { endpoint ->
YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result ->
result?.continuation to result?.items
}
}
}
.padding(all = 8.dp)
.size(20.dp)
)
}
} ?: artistResult?.exceptionOrNull()?.let { throwable ->
// LoadingOrError(
// errorMessage = throwable.javaClass.canonicalName,
// onRetry = {
// query {
// runBlocking {
// Database.artist(browseId).first()?.let(Database::update)
// }
// }
// }
// )
}
}
item("songs") {
if (songs.isEmpty()) return@item
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.background(colorPalette.background0)
.zIndex(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 32.dp)
) {
BasicText(
text = "Local tracks",
style = typography.m.semiBold,
modifier = Modifier
.padding(horizontal = 8.dp)
)
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(enabled = songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs
.shuffled()
.map(DetailedSong::asMediaItem)
},
itemContent = { album ->
AlbumItem(
album = album,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { albumRoute(album.info?.endpoint?.browseId) }
)
)
},
itemShimmer = {
AlbumItemShimmer(thumbnailSizeDp = thumbnailSizeDp)
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
)
}
}
itemsIndexed(
items = songs,
key = { _, song -> song.id },
contentType = { _, song -> song },
) { index, song ->
SongItem(
song = song,
thumbnailSizePx = songThumbnailSizePx,
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(
songs.map(DetailedSong::asMediaItem),
index
)
},
menuContent = {
InHistoryMediaItemMenu(song = song)
}
)
}
3 -> {
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
artistResult?.getOrNull()?.info?.let { description ->
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.background(colorPalette.background0)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 32.dp)
) {
BasicText(
text = "Information",
style = typography.m.semiBold,
modifier = Modifier
.padding(horizontal = 8.dp)
)
Row(
modifier = Modifier
.height(IntrinsicSize.Max)
.padding(all = 8.dp)
.fillMaxWidth()
) {
Canvas(
modifier = Modifier
.fillMaxHeight()
.width(48.dp)
) {
drawLine(
color = colorPalette.background2,
start = size.center.copy(y = 0f),
end = size.center.copy(y = size.height),
strokeWidth = 2.dp.toPx()
)
drawCircle(
color = colorPalette.background2,
center = size.center.copy(y = size.height),
radius = 4.dp.toPx()
ArtistContent(
artist = artist,
youtubeArtist = youtubeArtist,
isLoading = isLoading,
isError = isError,
stateSaver = YouTubeAlbumListSaver,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent,
itemsProvider = {
youtubeArtist
?.singlesEndpoint
?.let { endpoint ->
YouTube.items2(browseId, endpoint.params, YouTube.Item.Album::from)?.map { result ->
result?.continuation to result?.items
}
}
},
itemContent = { album ->
AlbumItem(
album = album,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = { albumRoute(album.info?.endpoint?.browseId) }
)
)
},
itemShimmer = {
AlbumItemShimmer(thumbnailSizeDp = thumbnailSizeDp)
}
BasicText(
text = description,
style = typography.xxs.secondary.medium.copy(
lineHeight = 24.sp,
textAlign = TextAlign.Justify
),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
)
}
4 -> ArtistLocalSongsList(
browseId = browseId,
artist = artist,
isLoading = isLoading,
isError = isError,
bookmarkIconContent = bookmarkIconContent,
shareIconContent = shareIconContent
)
}
}
}
}
}
}
private suspend fun fetchArtist(browseId: String): Result<Artist>? {
return YouTube.artist(browseId)
?.map { youtubeArtist ->
Artist(
id = browseId,
name = youtubeArtist.name ?: "",
thumbnailUrl = youtubeArtist.thumbnail?.url,
info = youtubeArtist.description,
shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId,
shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId,
radioVideoId = youtubeArtist.radioEndpoint?.videoId,
radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId,
timestamp = System.currentTimeMillis()
).also(Database::upsert)
}
}

View File

@@ -181,7 +181,7 @@ fun HomeArtistList(
)
BasicText(
text = artist.name,
text = artist.name ?: "",
style = typography.xxs.semiBold.center,
maxLines = 2,
overflow = TextOverflow.Ellipsis

View File

@@ -46,14 +46,13 @@ inline fun <T : YouTube.Item> SearchResult(
) {
val (_, typography) = LocalAppearance.current
var items by rememberSaveable(query, filter, stateSaver = stateSaver) {
var items by rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(listOf())
}
val (continuationResultState, fetch) = produceSaveableRelaunchableOneShotState(
initialValue = null,
stateSaver = StringResultSaver,
query, filter
stateSaver = StringResultSaver
) {
val token = value?.getOrNull()

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
@@ -380,6 +381,75 @@ fun AlbumItemShimmer(
}
}
@Composable
fun AlternativeAlbumItem(
album: YouTube.Item.Album,
thumbnailSizePx: Int,
thumbnailSizeDp: Dp,
modifier: Modifier = Modifier,
) {
val (_, typography, thumbnailShape) = LocalAppearance.current
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier
.padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp)
.width(thumbnailSizeDp)
) {
AsyncImage(
model = album.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.clip(thumbnailShape)
.size(thumbnailSizeDp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText(
text = album.info?.name ?: "",
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = album.year ?: "",
style = typography.xxs.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
)
}
}
}
@Composable
fun AlternativeAlbumItemPlaceholder(
thumbnailSizeDp: Dp,
modifier: Modifier = Modifier,
) {
val (colorPalette, _, thumbnailShape) = LocalAppearance.current
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier
.padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp)
.width(thumbnailSizeDp)
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.shimmer, shape = thumbnailShape)
.size(thumbnailSizeDp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
TextPlaceholder()
TextPlaceholder()
}
}
}
@Composable
fun ArtistItem(
artist: YouTube.Item.Artist,

View File

@@ -0,0 +1,66 @@
@file:OptIn(InternalComposeApi::class)
package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.State
import androidx.compose.runtime.currentComposer
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@Composable
@NonRestartableComposable
fun lazyEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
): () -> Unit {
val applyContext = currentComposer.applyCoroutineContext
val lazyEffect = remember(key1) {
LazyEffectImpl(applyContext, block)
}
return lazyEffect::calculate
}
class LazyEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
fun calculate() {
if (job == null) {
job = scope.launch(block = task)
}
}
override fun onRemembered() = Unit
override fun onForgotten() {
job?.cancel()
job = null
}
override fun onAbandoned() {
job?.cancel()
job = null
}
}

View File

@@ -33,6 +33,7 @@ const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics"
const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen"
const val homeScreenTabIndexKey = "homeScreenTabIndex"
const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex"
const val artistScreenTabIndexKey = "artistScreenTabIndex"
inline fun <reified T : Enum<T>> SharedPreferences.getEnum(
key: String,

View File

@@ -6,14 +6,17 @@ 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
@@ -123,19 +126,17 @@ fun <T> produceSaveableState(
fun <T> produceSaveableRelaunchableOneShotState(
initialValue: T,
stateSaver: Saver<T, out Any>,
key1: Any?,
key2: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): Pair<State<T>, () -> Unit> {
val result = rememberSaveable(stateSaver = stateSaver) {
mutableStateOf(initialValue)
}
var produced by rememberSaveable(key1, key2) {
var produced by rememberSaveable {
mutableStateOf(false)
}
val relaunchableEffect = relaunchableEffect(key1, key2) {
val relaunchableEffect = relaunchableEffect(Unit) {
if (!produced) {
ProduceSaveableStateScope(result, coroutineContext).producer()
produced = true
@@ -148,6 +149,70 @@ fun <T> produceSaveableRelaunchableOneShotState(
}
}
@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