diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt index 43792b7..67c712b 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -63,15 +63,15 @@ import it.vfsfitvnm.vimusic.ui.components.collapsedAnchor import it.vfsfitvnm.vimusic.ui.components.dismissedAnchor import it.vfsfitvnm.vimusic.ui.components.expandedAnchor import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState -import it.vfsfitvnm.vimusic.ui.screens.home.HomeScreen import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen +import it.vfsfitvnm.vimusic.ui.screens.home.HomeScreen +import it.vfsfitvnm.vimusic.ui.screens.player.PlayerView import it.vfsfitvnm.vimusic.ui.styling.Appearance import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.colorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.dynamicColorPaletteOf import it.vfsfitvnm.vimusic.ui.styling.typographyOf -import it.vfsfitvnm.vimusic.ui.screens.player.PlayerView import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey import it.vfsfitvnm.vimusic.utils.getEnum diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResult.kt similarity index 87% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultTab.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResult.kt index 4971fe8..a78686c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResult.kt @@ -2,6 +2,7 @@ package it.vfsfitvnm.vimusic.ui.screens.searchresult import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope @@ -22,11 +23,10 @@ import it.vfsfitvnm.youtubemusic.YouTube @ExperimentalAnimationApi @Composable -inline fun ItemSearchResultTab( +inline fun ItemSearchResult( query: String, filter: String, crossinline onSearchAgain: () -> Unit, - isArtists: Boolean = false, viewModel: ItemSearchResultViewModel = viewModel( key = query + filter, factory = object : ViewModelProvider.Factory { @@ -36,7 +36,8 @@ inline fun ItemSearchResultTab( } } ), - crossinline itemContent: @Composable (LazyItemScope.(I) -> Unit) + crossinline itemContent: @Composable LazyItemScope.(I) -> Unit, + noinline itemShimmer: @Composable BoxScope.() -> Unit, ) { LazyColumn( contentPadding = LocalPlayerAwarePaddingValues.current, @@ -45,7 +46,7 @@ inline fun ItemSearchResultTab( ) { item( key = "header", - contentType = 0 + contentType = 0, ) { Header( title = query, @@ -60,6 +61,7 @@ inline fun ItemSearchResultTab( items( items = viewModel.items, + key = { it.key!! }, itemContent = itemContent ) @@ -73,7 +75,8 @@ inline fun ItemSearchResultTab( item { SearchResultLoadingOrError( errorMessage = throwable.javaClass.canonicalName, - onRetry = viewModel::fetch + onRetry = viewModel::fetch, + shimmerContent = {} ) } } ?: viewModel.continuationResult?.let { @@ -88,7 +91,7 @@ inline fun ItemSearchResultTab( } ?: item(key = "loading") { SearchResultLoadingOrError( itemCount = if (viewModel.items.isEmpty()) 8 else 3, - isLoadingArtists = isArtists + shimmerContent = itemShimmer ) } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt index cf7057c..e754cf5 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt @@ -1,7 +1,6 @@ package it.vfsfitvnm.vimusic.ui.screens.searchresult import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel @@ -12,8 +11,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ItemSearchResultViewModel(private val query: String, private val filter: String) : ViewModel() { - val items = mutableStateListOf() +class ItemSearchResultViewModel( + private val query: String, + private val filter: String +) : ViewModel() { + var items by mutableStateOf(listOf()) var continuationResult by mutableStateOf?>(null) @@ -35,7 +37,7 @@ class ItemSearchResultViewModel(private val query: String, pri YouTube.search(query, filter, token) }?.map { searchResult -> @Suppress("UNCHECKED_CAST") - items.addAll(searchResult.items as List) + items = items.plus(searchResult.items as List).distinctBy(YouTube.Item::key) searchResult.continuation } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt index 7cbb0b5..3252377 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt @@ -1,9 +1,9 @@ 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.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.padding import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -21,17 +21,23 @@ import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.playlistRoute import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.SmallAlbumItem -import it.vfsfitvnm.vimusic.ui.views.SmallArtistItem -import it.vfsfitvnm.vimusic.ui.views.SmallPlaylistItem +import it.vfsfitvnm.vimusic.ui.views.AlbumItem +import it.vfsfitvnm.vimusic.ui.views.AlbumItemShimmer +import it.vfsfitvnm.vimusic.ui.views.ArtistItem +import it.vfsfitvnm.vimusic.ui.views.ArtistItemShimmer +import it.vfsfitvnm.vimusic.ui.views.PlaylistItem +import it.vfsfitvnm.vimusic.ui.views.PlaylistItemShimmer import it.vfsfitvnm.vimusic.ui.views.SmallSongItem -import it.vfsfitvnm.vimusic.ui.views.SmallVideoItem +import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer +import it.vfsfitvnm.vimusic.ui.views.VideoItem +import it.vfsfitvnm.vimusic.ui.views.VideoItemShimmer import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.forcePlay import it.vfsfitvnm.vimusic.utils.rememberPreference import it.vfsfitvnm.vimusic.utils.searchResultScreenTabIndexKey import it.vfsfitvnm.youtubemusic.YouTube +@ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { @@ -76,125 +82,139 @@ fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { when (tabIndex) { 0 -> { val binder = LocalPlayerServiceBinder.current - val thumbnailSizePx = Dimensions.thumbnails.song.px - - ItemSearchResultTab( - query = query, - filter = searchFilter, - onSearchAgain = onSearchAgain - ) { song -> - SmallSongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(song.asMediaItem) - binder?.setupRadio(song.info.endpoint) - } - ) - } - } - - 1 -> { val thumbnailSizeDp = Dimensions.thumbnails.song val thumbnailSizePx = thumbnailSizeDp.px - ItemSearchResultTab( - query = query, - filter = searchFilter, - onSearchAgain = onSearchAgain - ) { album -> - SmallAlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { albumRoute(album.info.endpoint?.browseId) } - ) - .padding( - vertical = Dimensions.itemsVerticalPadding, - horizontal = 16.dp - ) - ) - } - } - - 2 -> { - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - ItemSearchResultTab( + ItemSearchResult( query = query, filter = searchFilter, onSearchAgain = onSearchAgain, - isArtists = true - ) { artist -> - SmallArtistItem( - artist = artist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { artistRoute(artist.info.endpoint?.browseId) } - ) - .padding( - vertical = Dimensions.itemsVerticalPadding, - horizontal = 16.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) + } + ) + } + + 1 -> { + val thumbnailSizeDp = 108.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemSearchResult( + query = query, + filter = searchFilter, + onSearchAgain = onSearchAgain, + 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) + } + ) + } + + 2 -> { + val thumbnailSizeDp = 64.dp + val thumbnailSizePx = thumbnailSizeDp.px + + ItemSearchResult( + query = query, + filter = searchFilter, + onSearchAgain = onSearchAgain, + itemContent = { artist -> + ArtistItem( + artist = artist, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { artistRoute(artist.info.endpoint?.browseId) } + ) + ) + }, + itemShimmer = { + ArtistItemShimmer(thumbnailSizeDp = thumbnailSizeDp) + } + ) } 3 -> { val binder = LocalPlayerServiceBinder.current - val thumbnailSizePx = Dimensions.thumbnails.song.px + val thumbnailHeightDp = 72.dp + val thumbnailWidthDp = 128.dp - ItemSearchResultTab( + ItemSearchResult( query = query, filter = searchFilter, - onSearchAgain = onSearchAgain - ) { video -> - SmallVideoItem( - video = video, - thumbnailSizePx = thumbnailSizePx, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(video.asMediaItem) - binder?.setupRadio(video.info.endpoint) - } - ) - } + onSearchAgain = onSearchAgain, + itemContent = { video -> + VideoItem( + video = video, + thumbnailWidthDp = thumbnailWidthDp, + thumbnailHeightDp = thumbnailHeightDp, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(video.asMediaItem) + binder?.setupRadio(video.info.endpoint) + } + ) + }, + itemShimmer = { + VideoItemShimmer( + thumbnailHeightDp = thumbnailHeightDp, + thumbnailWidthDp = thumbnailWidthDp + ) + } + ) } 4, 5 -> { - val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizeDp = 108.dp val thumbnailSizePx = thumbnailSizeDp.px - ItemSearchResultTab( + ItemSearchResult( query = query, filter = searchFilter, - onSearchAgain = onSearchAgain - ) { playlist -> - SmallPlaylistItem( - playlist = playlist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { playlistRoute(playlist.info.endpoint?.browseId) } - ) - .padding( - vertical = Dimensions.itemsVerticalPadding, - horizontal = 16.dp - ) - ) - } + onSearchAgain = onSearchAgain, + itemContent = { playlist -> + PlaylistItem( + playlist = playlist, + thumbnailSizePx = thumbnailSizePx, + thumbnailSizeDp = thumbnailSizeDp, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { playlistRoute(playlist.info.endpoint?.browseId) } + ) + ) + }, + itemShimmer = { + PlaylistItemShimmer(thumbnailSizeDp = thumbnailSizeDp) + } + ) } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt index 93fb40a..fa59313 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt @@ -1,8 +1,13 @@ package it.vfsfitvnm.vimusic.ui.views import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,8 +15,11 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText +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 @@ -21,14 +29,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu 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.onOverlay +import it.vfsfitvnm.vimusic.ui.styling.overlay import it.vfsfitvnm.vimusic.ui.styling.shimmer import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.youtubemusic.YouTube @@ -44,6 +56,8 @@ fun SmallSongItemShimmer( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = Dimensions.itemsVerticalPadding) ) { Spacer( modifier = Modifier @@ -58,29 +72,6 @@ fun SmallSongItemShimmer( } } -@Composable -fun SmallArtistItemShimmer( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = CircleShape) - .size(thumbnailSizeDp) - ) - - TextPlaceholder() - } -} - - @ExperimentalAnimationApi @Composable fun SmallSongItem( @@ -102,74 +93,80 @@ fun SmallSongItem( ) } +@ExperimentalFoundationApi @ExperimentalAnimationApi @Composable -fun SmallVideoItem( +fun VideoItem( video: YouTube.Item.Video, - thumbnailSizePx: Int, + thumbnailHeightDp: Dp, + thumbnailWidthDp: Dp, onClick: () -> Unit, modifier: Modifier = Modifier ) { - SongItem( - thumbnailModel = video.thumbnail?.size(thumbnailSizePx), - title = video.info.name, - authors = (if (video.isOfficialMusicVideo) video.authors else video.views) - .joinToString("") { it.name }, - durationText = video.durationText, - onClick = onClick, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = video.asMediaItem) - }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun SmallPlaylistItem( - playlist: YouTube.Item.Playlist, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (_, typography) = LocalAppearance.current + val menuState = LocalMenuState.current + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier + .combinedClickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu(mediaItem = video.asMediaItem) + } + }, + onClick = onClick + ) + .fillMaxWidth() + .padding(vertical = Dimensions.itemsVerticalPadding) + .padding(horizontal = 16.dp) ) { - AsyncImage( - model = playlist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) + Box { + AsyncImage( + model = video.thumbnail?.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(width = thumbnailWidthDp, height = thumbnailHeightDp) + ) - Column( - modifier = Modifier - .weight(1f) - ) { + video.durationText?.let { durationText -> + BasicText( + text = durationText, + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + Column { BasicText( - text = playlist.info.name, + text = video.info.name, style = typography.xs.semiBold, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) + BasicText( - text = playlist.channel?.name ?: "", + text = video.authors.joinToString("") { it.name }, style = typography.xs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - } - playlist.songCount?.let { songCount -> BasicText( - text = "$songCount songs", - style = typography.xxs.secondary, + text = video.views.firstOrNull()?.name ?: "", + style = typography.xxs.medium.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -177,60 +174,208 @@ fun SmallPlaylistItem( } } +@ExperimentalFoundationApi +@ExperimentalAnimationApi @Composable -fun SmallAlbumItem( +fun VideoItemShimmer( + thumbnailHeightDp: Dp, + thumbnailWidthDp: Dp, + modifier: Modifier = Modifier +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = Dimensions.itemsVerticalPadding) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(width = thumbnailWidthDp, height = thumbnailHeightDp) + ) + + Column { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } +} + +@Composable +fun PlaylistItem( + playlist: YouTube.Item.Playlist, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() + ) { + Box { + AsyncImage( + model = playlist.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(thumbnailSizeDp) + ) + + playlist.songCount?.let { songCount -> + BasicText( + text = "$songCount", + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicText( + text = playlist.info.name, + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + BasicText( + text = playlist.channel?.name ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun PlaylistItemShimmer( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } +} + +@Composable +fun AlbumItem( album: YouTube.Item.Album, thumbnailSizePx: Int, thumbnailSizeDp: Dp, modifier: Modifier = Modifier, ) { - val (_, typography) = LocalAppearance.current + val (_, typography, thumbnailShape) = LocalAppearance.current Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() ) { AsyncImage( model = album.thumbnail?.size(thumbnailSizePx), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .clip(ThumbnailRoundness.shape) + .clip(thumbnailShape) .size(thumbnailSizeDp) ) - Column( - modifier = Modifier - .weight(1f) - ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { BasicText( text = album.info.name, style = typography.xs.semiBold, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) + BasicText( text = album.authors?.joinToString("") { it.name } ?: "", style = typography.xs.semiBold.secondary, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } - album.year?.let { year -> - BasicText( - text = year, - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + album.year?.let { year -> + BasicText( + text = year, + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + ) + } } } } @Composable -fun SmallArtistItem( +fun AlbumItemShimmer( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } +} + +@Composable +fun ArtistItem( artist: YouTube.Item.Artist, thumbnailSizePx: Int, thumbnailSizeDp: Dp, @@ -240,8 +385,10 @@ fun SmallArtistItem( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() ) { AsyncImage( model = artist.thumbnail?.size(thumbnailSizePx), @@ -251,23 +398,49 @@ fun SmallArtistItem( .size(thumbnailSizeDp) ) - BasicText( - text = artist.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicText( + text = artist.info.name, + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun ArtistItemShimmer( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (colorPalette) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) + .fillMaxWidth() + ) { + Spacer( modifier = Modifier - .weight(1f) + .background(color = colorPalette.shimmer, shape = CircleShape) + .size(thumbnailSizeDp) ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextPlaceholder() + } } } @Composable fun SearchResultLoadingOrError( itemCount: Int = 0, - isLoadingArtists: Boolean = false, errorMessage: String? = null, - onRetry: (() -> Unit)? = null + onRetry: (() -> Unit)? = null, + shimmerContent: @Composable BoxScope.() -> Unit, ) { LoadingOrError( errorMessage = errorMessage, @@ -275,23 +448,28 @@ fun SearchResultLoadingOrError( horizontalAlignment = Alignment.CenterHorizontally ) { repeat(itemCount) { index -> - if (isLoadingArtists) { - SmallArtistItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } else { - SmallSongItemShimmer( - thumbnailSizeDp = Dimensions.thumbnails.song, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } + Box( + modifier = Modifier + .alpha(1f - index * 0.125f), + content = shimmerContent + ) +// if (isLoadingArtists) { +// SmallArtistItemShimmer( +// thumbnailSizeDp = Dimensions.thumbnails.song, +// modifier = Modifier +// .alpha(1f - index * 0.125f) +// .fillMaxWidth() +// .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) +// ) +// } else { +// SmallSongItemShimmer( +// thumbnailSizeDp = Dimensions.thumbnails.song, +// modifier = Modifier +// .alpha(1f - index * 0.125f) +// .fillMaxWidth() +// .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) +// ) +// } } } } diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index c24f77b..5407590 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -177,6 +177,7 @@ object YouTube { sealed class Item { abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? + abstract val key: String? data class Song( val info: Info, @@ -185,6 +186,9 @@ object YouTube { val durationText: String?, override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? ) : Item() { + override val key: String? + get() = info.endpoint?.videoId + companion object : FromMusicShelfRendererContent { val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") @@ -232,6 +236,9 @@ object YouTube { val durationText: String?, override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? ) : Item() { + override val key: String? + get() = info.endpoint?.videoId + val isOfficialMusicVideo: Boolean get() = info .endpoint @@ -278,6 +285,9 @@ object YouTube { val year: String?, override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? ) : Item() { + override val key: String? + get() = info.endpoint?.browseId + companion object : FromMusicShelfRendererContent { val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") @@ -312,6 +322,9 @@ object YouTube { val info: Info, override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? ) : Item() { + override val key: String? + get() = info.endpoint?.browseId + companion object : FromMusicShelfRendererContent { val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") @@ -341,6 +354,9 @@ object YouTube { val songCount: Int?, override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail? ) : Item() { + override val key: String? + get() = info.endpoint?.browseId + companion object : FromMusicShelfRendererContent { override fun from(content: MusicShelfRenderer.Content): Playlist { val (mainRuns, otherRuns) = content.runs