Redesign SearchResultScreen (#172)

This commit is contained in:
vfsfitvnm 2022-09-23 18:24:18 +02:00
parent ef0567650c
commit 20de24bfb3
12 changed files with 657 additions and 605 deletions

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.medium
@ -30,7 +31,9 @@ fun Header(
titleContent = { titleContent = {
BasicText( BasicText(
text = title, text = title,
style = typography.xxl.medium style = typography.xxl.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
}, },
actionsContent = actionsContent actionsContent = actionsContent

View file

@ -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.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px 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.asMediaItem
import it.vfsfitvnm.vimusic.utils.enqueue import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex

View file

@ -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<YouTube.Item>()
}
var continuationResult by remember(searchFilter) {
mutableStateOf<Result<String?>?>(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)
)
}
}
}
}

View file

@ -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.BuiltInPlaylistScreen
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
import it.vfsfitvnm.vimusic.ui.screens.LocalPlaylistScreen 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.builtInPlaylistRoute
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
import it.vfsfitvnm.vimusic.ui.screens.intentUriRoute import it.vfsfitvnm.vimusic.ui.screens.intentUriRoute

View file

@ -40,7 +40,7 @@ import it.vfsfitvnm.vimusic.ui.components.BottomSheet
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.MusicBars import it.vfsfitvnm.vimusic.ui.components.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu 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.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.onOverlay import it.vfsfitvnm.vimusic.ui.styling.onOverlay

View file

@ -3,7 +3,6 @@ package it.vfsfitvnm.vimusic.ui.screens.search
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource

View file

@ -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 <I : YouTube.Item> ItemSearchResultTab(
query: String,
filter: String,
crossinline onSearchAgain: () -> Unit,
isArtists: Boolean = false,
viewModel: ItemSearchResultViewModel<I> = viewModel(
key = query + filter,
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ItemSearchResultViewModel<I>(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
)
}
}
}

View file

@ -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<T : YouTube.Item>(private val query: String, private val filter: String) : ViewModel() {
val items = mutableStateListOf<T>()
var continuationResult by mutableStateOf<Result<String?>?>(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<T>)
searchResult.continuation
}
}
}
}

View file

@ -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<YouTube.Item.Song>(
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<YouTube.Item.Album>(
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<YouTube.Item.Artist>(
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<YouTube.Item.Video>(
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<YouTube.Item.Playlist>(
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
)
)
}
}
}
}
}
}
}
}

View file

@ -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)
)
}
}
}
}

View file

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

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M436,80L76,80a44.05,44.05 0,0 0,-44 44L32,388a44.05,44.05 0,0 0,44 44L436,432a44.05,44.05 0,0 0,44 -44L480,124A44.05,44.05 0,0 0,436 80ZM112,388a12,12 0,0 1,-12 12L76,400a12,12 0,0 1,-12 -12L64,364a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM112,308a12,12 0,0 1,-12 12L76,320a12,12 0,0 1,-12 -12L64,284a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM112,228a12,12 0,0 1,-12 12L76,240a12,12 0,0 1,-12 -12L64,204a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM112,148a12,12 0,0 1,-12 12L76,160a12,12 0,0 1,-12 -12L64,124a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM353.68,272L158.32,272a16,16 0,0 1,0 -32L353.68,240a16,16 0,1 1,0 32ZM448,388a12,12 0,0 1,-12 12L412,400a12,12 0,0 1,-12 -12L400,364a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM448,308a12,12 0,0 1,-12 12L412,320a12,12 0,0 1,-12 -12L400,284a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM448,228a12,12 0,0 1,-12 12L412,240a12,12 0,0 1,-12 -12L400,204a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12ZM448,148a12,12 0,0 1,-12 12L412,160a12,12 0,0 1,-12 -12L400,124a12,12 0,0 1,12 -12h24a12,12 0,0 1,12 12Z"/>
</vector>