feat(search): enhance search functionality with recent search suggestions and improved UI components

This commit is contained in:
Kingkor Roy Tirtho 2026-10-14 16:29:45 +06:00
parent 93dfea9a30
commit 1ccd70a18c
6 changed files with 336 additions and 362 deletions

View File

@ -21,6 +21,12 @@
<string name="queue_filter_queue">Filter queue...</string>
<string name="queue_no_queue_entries">No queue entries</string>
<string name="search_placeholder">Search songs, artists, albums...</string>
<string name="search_clear_history">Clear all history</string>
<string name="search_filter_placeholder">Search...</string>
<string name="accessibility_selected">Selected</string>
<string name="settings_screen_title">Settings</string>
<string name="settings_section_plugins">Plugins</string>

View File

@ -19,23 +19,17 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -45,19 +39,13 @@ 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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.SheetDetent
import com.composeunstyled.Text
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledDropdownMenuItem
import com.composeunstyled.rememberModalBottomSheetState
import compose.icons.FeatherIcons
import compose.icons.feathericons.Check
@ -67,16 +55,19 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.gelBrush
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.accessibility_selected
import spotube.composeapp.generated.resources.search_filter_placeholder
data class AdaptiveMenuItem(
val icon: ImageVector? = null,
@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) {
if (isLargeScreen) {
UnstyledDropdownMenu(
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
panel = {
DropdownMenuPanel(
Modifier
.width(menuMinWidth)
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = AppTheme.shapes.medium,
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp)
.padding(4.dp)) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header()
}
val dropdownQueryState = rememberTextFieldState()
val query = dropdownQueryState.text.toString()
if (filter != null) {
MillenniumTextField(
state = dropdownQueryState,
label = "",
placeholder = "Search...",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
}
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val itemInteraction = remember { MutableInteractionSource() }
val itemHovered by itemInteraction.collectIsHoveredAsState()
val itemPressed by itemInteraction.collectIsPressedAsState()
val itemEnabled = item.enabled
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
val itemForeground = when {
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
itemHighlighted -> MillenniumTheme.colors.onAccent
else -> AppTheme.colors.onSurface
}
UnstyledDropdownMenuItem(
onClick = { item.onClick() },
enabled = itemEnabled,
indication = null,
interactionSource = itemInteraction) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (itemHighlighted) {
Modifier.millenniumSurface(
background = gelBrush(
MillenniumTheme.colors.accentTop,
MillenniumTheme.colors.accentBottom),
shape = RoundedCornerShape(8.dp))
} else {
Modifier
})
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val itemIcon = item.icon
when {
item.leadingContent != null -> item.leadingContent.invoke()
itemIcon != null -> MillenniumIcon(
imageVector = itemIcon,
contentDescription = null,
tint = itemForeground,
modifier = Modifier.size(20.dp))
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = AppTheme.typography.bodyMedium,
color = itemForeground)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = AppTheme.typography.bodySmall,
color = if (itemHighlighted) {
itemForeground.copy(alpha = 0.85f)
} else {
AppTheme.colors.onSurfaceVariant
})
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
}
}
}
}
},
anchor = { trigger { expanded = true } },
)
items = items.map { it.label },
onItemClick = { label ->
items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
expanded = false
},
minimumWidth = menuMinWidth,
anchor = { trigger { expanded = true } }
)
} else {
trigger { expanded = true }
if (expanded) {
@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent(
MillenniumTextField(
state = queryState,
label = "",
placeholder = "Search...",
placeholder = stringResource(Res.string.search_filter_placeholder),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(AppTheme.shapes.medium)
.clickable(
enabled = item.enabled,
onClick = {
item.onClick()
onDismiss()
})
.background(
color = if (item.selected) {
AppTheme.colors.secondary.copy(alpha = 0.12f)
} else {
Color.Transparent
},
shape = AppTheme.shapes.medium)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
when {
item.leadingContent != null -> item.leadingContent.invoke()
item.icon != null -> MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
})
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(22.dp),
tint = AppTheme.colors.onSurfaceVariant)
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
MillenniumListTile(
title = {
MillenniumText(
item.label,
style = AppTheme.typography.bodyLarge,
color = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
})
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
}
)
},
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
color = AppTheme.colors.onSurfaceVariant
)
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(22.dp),
tint = AppTheme.colors.onSurfaceVariant)
}
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
}
hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
onClick = {
item.onClick()
onDismiss()
},
minimumHeight = 56.dp
)
}
}
}

View File

@ -36,7 +36,12 @@ import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumTooltipHost
import org.spotube.millennium.composables.MillenniumTopAppBarTheme
import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties
import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme
import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
import org.spotube.millennium.theme.MillenniumLightThemeComponents
import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig
@ -201,7 +206,29 @@ fun SpotubeTheme(
}
val palette: MillenniumPalette = settings.palette
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
val darkTheme = MillenniumDarkThemeComponents
val lightTheme = MillenniumLightThemeComponents
MillenniumTheme(
dark = isDarkTheme,
config = MillenniumThemeConfig(
palette = palette,
dark = darkTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme(
properties = darkTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
),
light = lightTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme(
properties = lightTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
)
)
) {
val colors = appColorsFrom(MillenniumTheme.colors)
MillenniumTooltipHost {
CompositionLocalProvider(

View File

@ -19,13 +19,12 @@ package dev.krtirtho.spotube.modules.search
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumAutocomplete
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.animation.AnimatedContent
@ -78,8 +77,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons
import compose.icons.feathericons.X
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
@ -115,6 +112,10 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.search_clear_history
import spotube.composeapp.generated.resources.search_placeholder
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -145,8 +146,12 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList())
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList())
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
emptyList()
)
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(
emptyList()
)
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = blacklistedTracks.map { it.id }.toSet()
@ -172,7 +177,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")),
autoPlay = true,
startPosition = 0,
collectionEntry = null)
collectionEntry = null
)
}
}
@ -188,7 +194,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
audioPlayerQueue.addAllAfterCurrent(
listOf(
QueueEntry.StreamingTrack(
track = track,
url = ""
)
)
)
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
@ -197,6 +210,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
@ -217,6 +231,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
@ -236,7 +251,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = isTrackBlacklisted(track))
isBlacklisted = isTrackBlacklisted(track)
)
}
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList())
@ -251,7 +267,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = isTrackBlacklisted(track))
isBlacklisted = isTrackBlacklisted(track)
)
}
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
@ -269,7 +286,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MillenniumTheme.colors.background)) {
.background(MillenniumTheme.colors.background)
) {
ApplicationMainBar(backButton = false)
Column(
modifier = Modifier
@ -282,12 +300,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
SearchBar(
query = state.query,
onQueryChange = viewModel::onQueryChange,
recentSearches = state.recentSearches,
recentSearchSuggestions = state.recentSearchSuggestions,
onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search)
focusManager.clearFocus()
},
onRecentSearchRemove = viewModel::removeRecentSearch,
onClearAllRecentSearches = {
viewModel.clearAllRecentSearches()
focusManager.clearFocus()
@ -297,7 +314,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
SearchTabs(
types = state.supportedSearchTypes,
selectedType = selectedType,
onTabSelected = viewModel::onTabSelected)
onTabSelected = viewModel::onTabSelected
)
}
}
@ -305,14 +323,16 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
state.isLoadingSearchTypes -> {
SearchLoadingIndicator(
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == null -> {
SearchMessage(
message = "No search types available",
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.ALL -> {
@ -348,7 +368,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
// No-op for now, could show a dialog with all artists
},
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.TRACK -> {
@ -379,7 +400,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
// No-op for now, could show a dialog with all artists
},
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.PLAYLIST -> {
@ -388,7 +410,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
playlists = state.playlists,
onLoadNextPage = viewModel::loadNextPlaylists,
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.ALBUM -> {
@ -397,7 +420,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
albums = state.albums,
onLoadNextPage = viewModel::loadNextAlbums,
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.ARTIST -> {
@ -406,7 +430,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
artists = state.artists,
onLoadNextPage = viewModel::loadNextArtists,
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
selectedType == MetadataSupportedSearchType.USER -> {
@ -415,7 +440,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
users = state.users,
onLoadNextPage = viewModel::loadNextUsers,
modifier = Modifier
.fillMaxSize())
.fillMaxSize()
)
}
}
}
@ -439,11 +465,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
private fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
recentSearches: List<String>,
recentSearchSuggestions: List<String>,
onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit,
onClearAllRecentSearches: () -> Unit) {
val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty()
onClearAllRecentSearches: () -> Unit
) {
val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty()
val searchState = rememberTextFieldState(query)
LaunchedEffect(searchState) {
@ -455,106 +481,61 @@ private fun SearchBar(
}
}
val suggestions = remember(showRecentSearches, recentSearches) {
buildList {
if (showRecentSearches) {
add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches))
recentSearches.forEach { search ->
add(
SearchDropdownEntry.RecentSearch(
query = search,
onClick = { onRecentSearchClick(search) },
onRemove = { onRecentSearchRemove(search) })
)
}
}
}
}
Box(
MillenniumAutocomplete(
state = searchState,
suggestions = recentSearchSuggestions,
onSuggestionSelected = onRecentSearchClick,
suggestionLabel = { it },
leadingContent = {
MillenniumIcon(
imageVector = Iconsax.InconsaxClock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = AppTheme.colors.onSurfaceVariant
)
},
placeholder = stringResource(Res.string.search_placeholder),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)) {
MillenniumAutocomplete(
state = searchState,
suggestions = suggestions,
onSuggestionSelected = { entry -> entry.onClick() },
suggestionLabel = { entry ->
when (entry) {
is SearchDropdownEntry.ClearAll -> "Clear all history"
is SearchDropdownEntry.RecentSearch -> entry.query
}
},
placeholder = "Search songs, artists, albums...",
modifier = Modifier.fillMaxWidth(),
suggestionContent = { entry, _ -> SearchSuggestionRow(entry) })
}
}
private sealed interface SearchDropdownEntry {
val onClick: () -> Unit
data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry
data class RecentSearch(
val query: String,
override val onClick: () -> Unit,
val onRemove: () -> Unit) : SearchDropdownEntry
}
@Composable
private fun SearchSuggestionRow(entry: SearchDropdownEntry) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
when (entry) {
is SearchDropdownEntry.ClearAll -> {
MillenniumIcon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = null,
tint = AppTheme.colors.error,
modifier = Modifier.size(16.dp))
Text(
"Clear all history",
color = AppTheme.colors.error,
style = AppTheme.typography.bodyMedium)
}
is SearchDropdownEntry.RecentSearch -> {
MillenniumIcon(
imageVector = Iconsax.InconsaxClock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = AppTheme.colors.onSurfaceVariant)
Text(
entry.query,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = AppTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
MillenniumIconButton(
onClick = entry.onRemove,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = FeatherIcons.X,
contentDescription = "Remove",
modifier = Modifier.size(14.dp),
tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f))
}
.padding(bottom = 8.dp),
menuHeader = if (hasRecentSearchSuggestions) {
{
MillenniumListTile(
title = {
MillenniumText(
text = stringResource(Res.string.search_clear_history),
color = AppTheme.colors.error,
style = AppTheme.typography.bodyMedium
)
},
leading = {
MillenniumIcon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = AppTheme.colors.error
)
},
onClick = onClearAllRecentSearches,
minimumHeight = 36.dp,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
leadingSpacing = 8.dp,
shape = AppTheme.shapes.small
)
}
} else {
null
}
}
)
}
@Composable
private fun SearchTabs(
types: List<MetadataSupportedSearchType>,
selectedType: MetadataSupportedSearchType?,
onTabSelected: (MetadataSupportedSearchType) -> Unit) {
onTabSelected: (MetadataSupportedSearchType) -> Unit
) {
val listState = rememberLazyListState()
LazyRow(
state = listState,
@ -570,7 +551,8 @@ private fun SearchTabs(
MillenniumButtonStyle.Primary
} else {
MillenniumButtonStyle.Secondary
}) {
}
) {
Text(type.tabTitle())
}
}
@ -600,7 +582,8 @@ private fun SearchAllTab(
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
if (query.isBlank()) {
SearchMessage(message = "Start typing to search", modifier = modifier)
return
@ -624,7 +607,8 @@ private fun SearchAllTab(
ErrorDisplay(
errorMessage = hasAnyError,
onRetry = { },
modifier = modifier)
modifier = modifier
)
return
}
@ -665,7 +649,8 @@ private fun SearchAllTab(
text = "Tracks",
style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp))
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)
)
}
if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) {
if (tracks.isLoading && tracks.items.isEmpty()) {
@ -743,7 +728,8 @@ private fun SearchTracksTab(
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
if (query.isBlank()) {
SearchMessage(message = "Start typing to search tracks", modifier = modifier)
return
@ -775,7 +761,8 @@ private fun SearchTracksTab(
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
simplified = true,
modifier = modifier)
modifier = modifier
)
}
@Composable
@ -783,7 +770,8 @@ private fun SearchAlbumsTab(
query: String,
albums: SearchPagedState<MetadataAlbum.Basic>,
onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
SearchGridTab(
query = query,
items = albums.items,
@ -793,7 +781,8 @@ private fun SearchAlbumsTab(
onLoadNextPage = onLoadNextPage,
emptyMessage = "No albums found",
loadingMessage = "Loading albums...",
modifier = modifier) { album ->
modifier = modifier
) { album ->
AlbumCard(album = album, modifier = Modifier.fillMaxWidth())
}
}
@ -803,7 +792,8 @@ private fun SearchArtistsTab(
query: String,
artists: SearchPagedState<MetadataArtist.Basic>,
onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
SearchGridTab(
query = query,
items = artists.items,
@ -813,7 +803,8 @@ private fun SearchArtistsTab(
onLoadNextPage = onLoadNextPage,
emptyMessage = "No artists found",
loadingMessage = "Loading artists...",
modifier = modifier) { artist ->
modifier = modifier
) { artist ->
ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth())
}
}
@ -823,7 +814,8 @@ private fun SearchPlaylistsTab(
query: String,
playlists: SearchPagedState<MetadataPlaylist>,
onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
SearchGridTab(
query = query,
items = playlists.items,
@ -833,7 +825,8 @@ private fun SearchPlaylistsTab(
onLoadNextPage = onLoadNextPage,
emptyMessage = "No playlists found",
loadingMessage = "Loading playlists...",
modifier = modifier) { playlist ->
modifier = modifier
) { playlist ->
PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth())
}
}
@ -843,7 +836,8 @@ private fun SearchUsersTab(
query: String,
users: SearchPagedState<MetadataUser>,
onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
SearchGridTab(
query = query,
items = users.items,
@ -853,7 +847,8 @@ private fun SearchUsersTab(
onLoadNextPage = onLoadNextPage,
emptyMessage = "No users found",
loadingMessage = "Loading users...",
modifier = modifier) { user ->
modifier = modifier
) { user ->
UserCard(user = user, modifier = Modifier.fillMaxWidth())
}
}
@ -870,7 +865,8 @@ private fun <T> SearchGridTab(
emptyMessage: String,
loadingMessage: String,
modifier: Modifier = Modifier,
itemContent: @Composable (T) -> Unit) {
itemContent: @Composable (T) -> Unit
) {
if (query.isBlank()) {
SearchMessage(message = "Start typing to search", modifier = modifier)
return
@ -897,14 +893,16 @@ private fun <T> SearchGridTab(
isLoading && items.isEmpty() -> {
SearchLoadingIndicator(
message = loadingMessage,
modifier = modifier)
modifier = modifier
)
}
error != null && items.isEmpty() -> {
ErrorDisplay(
errorMessage = error,
onRetry = onLoadNextPage,
modifier = modifier)
modifier = modifier
)
}
!isLoading && items.isEmpty() -> {
@ -918,7 +916,8 @@ private fun <T> SearchGridTab(
modifier = modifier.padding(horizontal = 12.dp),
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items) { item ->
itemContent(item)
}
@ -929,7 +928,8 @@ private fun <T> SearchGridTab(
PlayableCard(
title = "Sample Title",
subtitle = "Sample Subtitle",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}
@ -942,32 +942,38 @@ private fun <T> SearchGridTab(
private fun SearchHorizontalSection(
title: String,
onSeeAll: () -> Unit,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) {
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
fontWeight = FontWeight.SemiBold
)
Text(
text = "See all",
style = AppTheme.typography.labelLarge,
color = AppTheme.colors.primary,
modifier = Modifier.clickable(onClick = onSeeAll))
modifier = Modifier.clickable(onClick = onSeeAll)
)
}
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
content = content)
content = content
)
}
}
@ -975,31 +981,37 @@ private fun SearchHorizontalSection(
private fun SearchTrackRow(
index: Int,
track: MetadataTrack,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = (index + 1).toString(),
style = AppTheme.typography.labelMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.End)
textAlign = TextAlign.End
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = track.title,
style = AppTheme.typography.bodyLarge,
maxLines = 1)
maxLines = 1
)
Text(
text = track.artists.joinToString { it.name },
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1)
maxLines = 1
)
}
if (track.album != null) Text(
@ -1007,7 +1019,8 @@ private fun SearchTrackRow(
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.weight(1f))
modifier = Modifier.weight(1f)
)
}
}
@ -1018,27 +1031,33 @@ private val WarmSearchMessages = listOf(
"Dusting off the vinyl...",
"Consulting the music gods...",
"Tuning the antennas...",
"Flipping through the records...")
"Flipping through the records..."
)
@Composable
private fun SearchLoadingIndicator(
message: String? = null,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "search-loading")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart),
label = "icon-rotation")
repeatMode = RepeatMode.Restart
),
label = "icon-rotation"
)
val bounce by infiniteTransition.animateFloat(
initialValue = -8f,
targetValue = 8f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse),
label = "icon-bounce")
repeatMode = RepeatMode.Reverse
),
label = "icon-bounce"
)
val messageIndex by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = WarmSearchMessages.size.toFloat(),
@ -1047,18 +1066,22 @@ private fun SearchLoadingIndicator(
durationMillis = WarmSearchMessages.size * 2000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart),
label = "message-cycle")
repeatMode = RepeatMode.Restart
),
label = "message-cycle"
)
val displayMessage =
message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier.size(64.dp),
contentAlignment = Alignment.Center) {
contentAlignment = Alignment.Center
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Searching",
@ -1073,18 +1096,21 @@ private fun SearchLoadingIndicator(
MillenniumIndeterminateProgress(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp))
.padding(horizontal = 32.dp, vertical = 16.dp)
)
AnimatedContent(
targetState = displayMessage,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
},
label = "message-transition") { msg ->
label = "message-transition"
) { msg ->
Text(
text = msg,
color = AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Center)
textAlign = TextAlign.Center
)
}
}
}
@ -1093,30 +1119,35 @@ private fun SearchLoadingIndicator(
private fun SearchMessage(
message: String,
modifier: Modifier = Modifier,
isError: Boolean = false) {
isError: Boolean = false
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center) {
contentAlignment = Alignment.Center
) {
Text(
text = message,
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp))
modifier = Modifier.padding(16.dp)
)
}
}
@Composable
private fun SearchMessageInline(
message: String,
isError: Boolean = false) {
isError: Boolean = false
) {
Text(
text = message,
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp))
.padding(horizontal = 16.dp)
)
}
@Composable

View File

@ -59,7 +59,14 @@ data class SearchScreenState(
val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(),
val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(),
val playlists: SearchPagedState<MetadataPlaylist> = SearchPagedState(),
val users: SearchPagedState<MetadataUser> = SearchPagedState())
val users: SearchPagedState<MetadataUser> = SearchPagedState()) {
val recentSearchSuggestions: List<String>
get() = if (query.isBlank()) {
recentSearches
} else {
recentSearches.filter { it.contains(query, ignoreCase = true) }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class SearchScreenViewModel(

View File

@ -50,10 +50,14 @@ import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent
import org.spotube.millennium.composables.MillenniumSidebarFooter
@ -89,7 +93,7 @@ fun AppSidebar(
) {
MillenniumSidebarHeader {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
@ -104,7 +108,14 @@ fun AppSidebar(
color = AppTheme.colors.onSurface
)
}
MillenniumSidebarTrigger()
MillenniumSidebarTrigger {
MillenniumIconButton(
if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken,
contentDescription = "Toggle Sidebar",
style = MillenniumIconButtonStyle.Quiet,
onClick = { it.toggle() }
)
}
}
}
MillenniumSidebarContent {
@ -120,8 +131,9 @@ fun AppSidebar(
MillenniumSidebarGroupContent {
MillenniumSidebarMenu {
entry.items.forEach { item ->
val selected = navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
val selected =
navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
SidebarNavItem(
item = item,
selected = selected,
@ -154,13 +166,15 @@ fun AppSidebar(
label = supportLabel,
onClick = { openSupport() },
contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2)
icon = Iconsax.IconsaxHeart2
)
MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2)
icon = Iconsax.IconsaxSetting2
)
}
}
}
@ -169,11 +183,13 @@ fun AppSidebar(
private fun SidebarNavItem(
item: NavigationItem.Tab<*>,
selected: Boolean,
onClick: () -> Unit) {
onClick: () -> Unit
) {
MillenniumSidebarItem(
label = item.title,
onClick = onClick,
selected = selected,
contentDescription = item.title,
icon = item.icon)
icon = item.icon
)
}