Redesign SearchResultScreen (#172)
This commit is contained in:
parent
ef0567650c
commit
20de24bfb3
12 changed files with 657 additions and 605 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
9
app/src/main/res/drawable/film.xml
Normal file
9
app/src/main/res/drawable/film.xml
Normal 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>
|
Loading…
Reference in a new issue