From 20de24bfb3a9ea2f902088e4b0cc6a62ae305e2f Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Fri, 23 Sep 2022 18:24:18 +0200 Subject: [PATCH] Redesign SearchResultScreen (#172) --- .../vimusic/ui/components/themed/Header.kt | 5 +- .../vimusic/ui/screens/IntentUriScreen.kt | 2 + .../vimusic/ui/screens/SearchResultScreen.kt | 601 ------------------ .../vimusic/ui/screens/home/HomeScreen.kt | 2 +- .../ui/screens/player/PlayerBottomSheet.kt | 2 +- .../ui/screens/search/OnlineSearchTab.kt | 1 - .../searchresult/ItemSearchResultTab.kt | 95 +++ .../searchresult/ItemSearchResultViewModel.kt | 43 ++ .../searchresult/SearchResultScreen.kt | 204 ++++++ .../vimusic/ui/views/YouTubeItems.kt | 297 +++++++++ .../it/vfsfitvnm/vimusic/utils/Preferences.kt | 1 + app/src/main/res/drawable/film.xml | 9 + 12 files changed, 657 insertions(+), 605 deletions(-) delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultTab.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt create mode 100644 app/src/main/res/drawable/film.xml diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt index 4d308a0..bdcc125 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Header.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.medium @@ -30,7 +31,9 @@ fun Header( titleContent = { BasicText( text = title, - style = typography.xxl.medium + style = typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) }, actionsContent = actionsContent diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt index 675a8e0..c475ea2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentUriScreen.kt @@ -43,6 +43,8 @@ import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog 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.SmallSongItem +import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer import it.vfsfitvnm.vimusic.utils.asMediaItem import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt deleted file mode 100644 index a0f5973..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt +++ /dev/null @@ -1,601 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -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.Arrangement -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.items -import androidx.compose.foundation.lazy.rememberLazyListState -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.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -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.route.RouteHandler -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.ui.components.ChipGroup -import it.vfsfitvnm.vimusic.ui.components.ChipItem -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextCard -import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.styling.shimmer -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlay -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.relaunchableEffect -import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.searchFilterKey -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { - val (colorPalette, typography) = LocalAppearance.current - val binder = LocalPlayerServiceBinder.current - - var searchFilter by rememberPreference(searchFilterKey, YouTube.Item.Song.Filter.value) - - val lazyListState = rememberLazyListState() - - val items = remember(searchFilter) { - mutableStateListOf() - } - - var continuationResult by remember(searchFilter) { - mutableStateOf?>(null) - } - - val onLoad = relaunchableEffect(searchFilter) { - withContext(Dispatchers.Main) { - val token = continuationResult?.getOrNull() - - continuationResult = null - - continuationResult = withContext(Dispatchers.IO) { - YouTube.search(query, searchFilter, token) - }?.map { searchResult -> - items.addAll(searchResult.items) - searchResult.continuation - } - } - } - - val thumbnailSizePx = Dimensions.thumbnails.song.px - - RouteHandler(listenToGlobalEmitter = true) { - albumRoute { browseId -> - AlbumScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - playlistRoute { browseId -> - PlaylistScreen( - browseId = browseId ?: "browseId cannot be null" - ) - } - - host { - LazyColumn( - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item { - TopAppBar( - modifier = Modifier - .height(52.dp) - ) { - Image( - painter = painterResource(R.drawable.chevron_back), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(onClick = pop) - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - - BasicText( - text = query, - style = typography.m.semiBold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onSearchAgain - ) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - } - } - - item { - ChipGroup( - items = listOf( - ChipItem( - text = "Songs", - value = YouTube.Item.Song.Filter.value - ), - ChipItem( - text = "Albums", - value = YouTube.Item.Album.Filter.value - ), - ChipItem( - text = "Artists", - value = YouTube.Item.Artist.Filter.value - ), - ChipItem( - text = "Videos", - value = YouTube.Item.Video.Filter.value - ), - ChipItem( - text = "Playlists", - value = YouTube.Item.CommunityPlaylist.Filter.value - ), - ChipItem( - text = "Featured playlists", - value = YouTube.Item.FeaturedPlaylist.Filter.value - ), - ), - value = searchFilter, - selectedBackgroundColor = colorPalette.accent, - unselectedBackgroundColor = colorPalette.background1, - selectedTextStyle = typography.xs.medium.color(colorPalette.onAccent), - unselectedTextStyle = typography.xs.medium, - shape = RoundedCornerShape(36.dp), - onValueChanged = { - searchFilter = it - }, - modifier = Modifier - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - ) - } - - items( - items = items, - contentType = { it } - ) { item -> - SmallItem( - item = item, - thumbnailSizeDp = Dimensions.thumbnails.song, - thumbnailSizePx = thumbnailSizePx, - onClick = { - when (item) { - is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Playlist -> playlistRoute(item.info.endpoint!!.browseId) - is YouTube.Item.Song -> { - binder?.stopRadio() - binder?.player?.forcePlay(item.asMediaItem) - binder?.setupRadio(item.info.endpoint) - } - is YouTube.Item.Video -> { - binder?.stopRadio() - binder?.player?.forcePlay(item.asMediaItem) - binder?.setupRadio(item.info.endpoint) - } - } - } - ) - } - - continuationResult?.getOrNull()?.let { - if (items.isNotEmpty()) { - item { - SideEffect(onLoad) - } - } - } ?: continuationResult?.exceptionOrNull()?.let { throwable -> - item { - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = onLoad - ) - } - } ?: continuationResult?.let { - if (items.isEmpty()) { - item { - TextCard(icon = R.drawable.sad) { - Title(text = "No results found") - Text(text = "Please try a different query or category.") - } - } - } - } ?: item(key = "loading") { - LoadingOrError( - itemCount = if (items.isEmpty()) 8 else 3, - isLoadingArtists = searchFilter == YouTube.Item.Artist.Filter.value - ) - } - } - } - } -} - -@Composable -fun SmallSongItemShimmer( - thumbnailSizeDp: Dp, - modifier: Modifier = Modifier -) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - Column { - TextPlaceholder() - TextPlaceholder() - } - } -} - -@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 SmallItem( - item: YouTube.Item, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - when (item) { - is YouTube.Item.Artist -> SmallArtistItem( - artist = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - is YouTube.Item.Song -> SmallSongItem( - song = item, - thumbnailSizePx = thumbnailSizePx, - onClick = onClick, - modifier = modifier - ) - is YouTube.Item.Album -> SmallAlbumItem( - album = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - is YouTube.Item.Video -> SmallVideoItem( - video = item, - thumbnailSizePx = thumbnailSizePx, - onClick = onClick, - modifier = modifier - ) - is YouTube.Item.Playlist -> SmallPlaylistItem( - playlist = item, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = modifier - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick - ) - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - ) - } -} - -@ExperimentalAnimationApi -@Composable -fun SmallSongItem( - song: YouTube.Item.Song, - thumbnailSizePx: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - SongItem( - thumbnailModel = song.thumbnail?.size(thumbnailSizePx), - title = song.info.name, - authors = song.authors.joinToString("") { it.name }, - durationText = song.durationText, - onClick = onClick, - menuContent = { - NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) - }, - modifier = modifier - ) -} - -@ExperimentalAnimationApi -@Composable -fun SmallVideoItem( - video: YouTube.Item.Video, - thumbnailSizePx: Int, - 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, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = playlist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = playlist.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = playlist.channel?.name ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - playlist.songCount?.let { songCount -> - BasicText( - text = "$songCount songs", - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun SmallAlbumItem( - album: YouTube.Item.Album, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = album.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) - - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = album.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = album.authors?.joinToString("") { it.name } ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - album.year?.let { year -> - BasicText( - text = year, - style = typography.xxs.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -fun SmallArtistItem( - artist: YouTube.Item.Artist, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, - modifier: Modifier = Modifier, -) { - val (_, typography) = LocalAppearance.current - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier - ) { - AsyncImage( - model = artist.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - modifier = Modifier - .clip(CircleShape) - .size(thumbnailSizeDp) - ) - - BasicText( - text = artist.info.name, - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - ) - } -} - -@Composable -private fun LoadingOrError( - itemCount: Int = 0, - isLoadingArtists: Boolean = false, - errorMessage: String? = null, - onRetry: (() -> Unit)? = null -) { - LoadingOrError( - errorMessage = errorMessage, - onRetry = onRetry, - 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) - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt index 13fe42b..8d4e28a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/home/HomeScreen.kt @@ -14,7 +14,7 @@ import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.screens.BuiltInPlaylistScreen import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen import it.vfsfitvnm.vimusic.ui.screens.LocalPlaylistScreen -import it.vfsfitvnm.vimusic.ui.screens.SearchResultScreen +import it.vfsfitvnm.vimusic.ui.screens.searchresult.SearchResultScreen import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.intentUriRoute diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt index e3c9da0..11d51a5 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/player/PlayerBottomSheet.kt @@ -40,7 +40,7 @@ import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.components.MusicBars import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu -import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer +import it.vfsfitvnm.vimusic.ui.views.SmallSongItemShimmer import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.onOverlay diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt index 9e24905..de99708 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt @@ -3,7 +3,6 @@ package it.vfsfitvnm.vimusic.ui.screens.search import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource 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/ItemSearchResultTab.kt new file mode 100644 index 0000000..4971fe8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultTab.kt @@ -0,0 +1,95 @@ +package it.vfsfitvnm.vimusic.ui.screens.searchresult + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.gestures.detectTapGestures +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.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.TextCard +import it.vfsfitvnm.vimusic.ui.views.SearchResultLoadingOrError +import it.vfsfitvnm.youtubemusic.YouTube + +@ExperimentalAnimationApi +@Composable +inline fun ItemSearchResultTab( + query: String, + filter: String, + crossinline onSearchAgain: () -> Unit, + isArtists: Boolean = false, + viewModel: ItemSearchResultViewModel = viewModel( + key = query + filter, + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ItemSearchResultViewModel(query, filter) as T + } + } + ), + crossinline itemContent: @Composable (LazyItemScope.(I) -> Unit) +) { + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + title = query, + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + onSearchAgain() + } + } + ) + } + + items( + items = viewModel.items, + itemContent = itemContent + ) + + viewModel.continuationResult?.getOrNull()?.let { + if (viewModel.items.isNotEmpty()) { + item { + SideEffect(viewModel::fetch) + } + } + } ?: viewModel.continuationResult?.exceptionOrNull()?.let { throwable -> + item { + SearchResultLoadingOrError( + errorMessage = throwable.javaClass.canonicalName, + onRetry = viewModel::fetch + ) + } + } ?: viewModel.continuationResult?.let { + if (viewModel.items.isEmpty()) { + item { + TextCard(icon = R.drawable.sad) { + Title(text = "No results found") + Text(text = "Please try a different query or category.") + } + } + } + } ?: item(key = "loading") { + SearchResultLoadingOrError( + itemCount = if (viewModel.items.isEmpty()) 8 else 3, + isLoadingArtists = isArtists + ) + } + } +} 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 new file mode 100644 index 0000000..cf7057c --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/ItemSearchResultViewModel.kt @@ -0,0 +1,43 @@ +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 +import androidx.lifecycle.viewModelScope +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +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() + + var continuationResult by mutableStateOf?>(null) + + private var job: Job? = null + + init { + fetch() + } + + fun fetch() { + job?.cancel() + + viewModelScope.launch { + val token = continuationResult?.getOrNull() + + continuationResult = null + + continuationResult = withContext(Dispatchers.IO) { + YouTube.search(query, filter, token) + }?.map { searchResult -> + @Suppress("UNCHECKED_CAST") + items.addAll(searchResult.items as List) + 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 new file mode 100644 index 0000000..7cbb0b5 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/searchresult/SearchResultScreen.kt @@ -0,0 +1,204 @@ +package it.vfsfitvnm.vimusic.ui.screens.searchresult + +import androidx.compose.animation.ExperimentalAnimationApi +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 +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier +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.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.PlaylistScreen +import it.vfsfitvnm.vimusic.ui.screens.albumRoute +import it.vfsfitvnm.vimusic.ui.screens.artistRoute +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.SmallSongItem +import it.vfsfitvnm.vimusic.ui.views.SmallVideoItem +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 + +@ExperimentalAnimationApi +@Composable +fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { + val saveableStateHolder = rememberSaveableStateHolder() + val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0) + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + playlistRoute { browseId -> + PlaylistScreen( + browseId = browseId ?: "browseId cannot be null" + ) + } + + host { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabIndexChanges, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + Item(1, "Albums", R.drawable.disc) + Item(2, "Artists", R.drawable.person) + Item(3, "Videos", R.drawable.film) + Item(4, "Playlists", R.drawable.playlist) + Item(5, "Featured", R.drawable.playlist) + } + ) { tabIndex -> + val searchFilter = when (tabIndex) { + 0 -> YouTube.Item.Song.Filter + 1 -> YouTube.Item.Album.Filter + 2 -> YouTube.Item.Artist.Filter + 3 -> YouTube.Item.Video.Filter + 4 -> YouTube.Item.CommunityPlaylist.Filter + 5 -> YouTube.Item.FeaturedPlaylist.Filter + else -> error("unreachable") + }.value + + saveableStateHolder.SaveableStateProvider(tabIndex) { + 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( + 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 + ) + ) + } + } + 3 -> { + val binder = LocalPlayerServiceBinder.current + val thumbnailSizePx = Dimensions.thumbnails.song.px + + ItemSearchResultTab( + 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) + } + ) + } + } + + 4, 5 -> { + val thumbnailSizeDp = Dimensions.thumbnails.song + val thumbnailSizePx = thumbnailSizeDp.px + + ItemSearchResultTab( + 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 + ) + ) + } + } + } + } + } + } + } +} 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 new file mode 100644 index 0000000..93fb40a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/YouTubeItems.kt @@ -0,0 +1,297 @@ +package it.vfsfitvnm.vimusic.ui.views + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +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.layout.ContentScale +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.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.shimmer +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.youtubemusic.YouTube + +@Composable +fun SmallSongItemShimmer( + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier +) { + val (colorPalette, _, thumbnailShape) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSizeDp) + ) + + Column { + TextPlaceholder() + TextPlaceholder() + } + } +} + +@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( + song: YouTube.Item.Song, + thumbnailSizePx: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + SongItem( + thumbnailModel = song.thumbnail?.size(thumbnailSizePx), + title = song.info.name, + authors = song.authors.joinToString("") { it.name }, + durationText = song.durationText, + onClick = onClick, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + }, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun SmallVideoItem( + video: YouTube.Item.Video, + thumbnailSizePx: Int, + 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 + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + AsyncImage( + model = playlist.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(ThumbnailRoundness.shape) + .size(thumbnailSizeDp) + ) + + Column( + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = playlist.info.name, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = playlist.channel?.name ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + playlist.songCount?.let { songCount -> + BasicText( + text = "$songCount songs", + style = typography.xxs.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SmallAlbumItem( + album: YouTube.Item.Album, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (_, typography) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + AsyncImage( + model = album.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(ThumbnailRoundness.shape) + .size(thumbnailSizeDp) + ) + + Column( + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = album.info.name, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = album.authors?.joinToString("") { it.name } ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + album.year?.let { year -> + BasicText( + text = year, + style = typography.xxs.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SmallArtistItem( + artist: YouTube.Item.Artist, + thumbnailSizePx: Int, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier, +) { + val (_, typography) = LocalAppearance.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + AsyncImage( + model = artist.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(thumbnailSizeDp) + ) + + BasicText( + text = artist.info.name, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + ) + } +} + +@Composable +fun SearchResultLoadingOrError( + itemCount: Int = 0, + isLoadingArtists: Boolean = false, + errorMessage: String? = null, + onRetry: (() -> Unit)? = null +) { + LoadingOrError( + errorMessage = errorMessage, + onRetry = onRetry, + 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) + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index 945bc3f..aafcb71 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -29,6 +29,7 @@ const val persistentQueueKey = "persistentQueue" const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" const val homeScreenTabIndexKey = "homeScreenTabIndex" +const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" inline fun > SharedPreferences.getEnum( key: String, diff --git a/app/src/main/res/drawable/film.xml b/app/src/main/res/drawable/film.xml new file mode 100644 index 0000000..5e334a8 --- /dev/null +++ b/app/src/main/res/drawable/film.xml @@ -0,0 +1,9 @@ + + +