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_filter_queue">Filter queue...</string>
<string name="queue_no_queue_entries">No queue entries</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_screen_title">Settings</string>
<string name="settings_section_plugins">Plugins</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.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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape 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.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -45,19 +39,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
import com.composeunstyled.Text
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledDropdownMenuItem
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash 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.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.Res
import org.spotube.millennium.theme.gelBrush import spotube.composeapp.generated.resources.accessibility_selected
import org.spotube.millennium.theme.millenniumPanelBrush import spotube.composeapp.generated.resources.search_filter_placeholder
import org.spotube.millennium.theme.millenniumSurface
data class AdaptiveMenuItem( data class AdaptiveMenuItem(
val icon: ImageVector? = null, val icon: ImageVector? = null,
@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) { Box(modifier = modifier) {
if (isLargeScreen) { if (isLargeScreen) {
UnstyledDropdownMenu( MillenniumDropdownMenu(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = it }, onExpandedChange = { expanded = it },
panel = { items = items.map { it.label },
DropdownMenuPanel( onItemClick = { label ->
Modifier items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
.width(menuMinWidth) expanded = false
.millenniumSurface( },
background = millenniumPanelBrush(MillenniumTheme.colors), minimumWidth = menuMinWidth,
shape = AppTheme.shapes.medium, anchor = { trigger { expanded = true } }
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 } },
)
} else { } else {
trigger { expanded = true } trigger { expanded = true }
if (expanded) { if (expanded) {
@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent(
MillenniumTextField( MillenniumTextField(
state = queryState, state = queryState,
label = "", label = "",
placeholder = "Search...", placeholder = stringResource(Res.string.search_filter_placeholder),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) .padding(horizontal = 12.dp, vertical = 8.dp))
@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) { if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
} }
Row( MillenniumListTile(
modifier = Modifier title = {
.fillMaxWidth() MillenniumText(
.clip(AppTheme.shapes.medium) item.label,
.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,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
AppTheme.colors.onSurface AppTheme.colors.onSurface
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) 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, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
} },
leading = when {
if (item.selected && (item.icon != null || item.leadingContent != null)) { item.leadingContent != null -> item.leadingContent
MillenniumIcon( item.icon != null -> {
imageVector = FeatherIcons.Check, {
contentDescription = "Selected", MillenniumIcon(
modifier = Modifier.size(22.dp), imageVector = item.icon,
tint = AppTheme.colors.onSurfaceVariant) 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.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumTooltipHost 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.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
import org.spotube.millennium.theme.MillenniumLightThemeComponents
import org.spotube.millennium.theme.MillenniumPalette import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig import org.spotube.millennium.theme.MillenniumThemeConfig
@ -201,7 +206,29 @@ fun SpotubeTheme(
} }
val palette: MillenniumPalette = settings.palette 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) val colors = appColorsFrom(MillenniumTheme.colors)
MillenniumTooltipHost { MillenniumTooltipHost {
CompositionLocalProvider( CompositionLocalProvider(

View File

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

View File

@ -59,7 +59,14 @@ data class SearchScreenState(
val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(), val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(),
val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(), val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(),
val playlists: SearchPagedState<MetadataPlaylist> = 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) @OptIn(ExperimentalCoroutinesApi::class)
class SearchScreenViewModel( 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 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 dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject 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.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarContent
import org.spotube.millennium.composables.MillenniumSidebarFooter import org.spotube.millennium.composables.MillenniumSidebarFooter
@ -89,7 +93,7 @@ fun AppSidebar(
) { ) {
MillenniumSidebarHeader { MillenniumSidebarHeader {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -104,7 +108,14 @@ fun AppSidebar(
color = AppTheme.colors.onSurface 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 { MillenniumSidebarContent {
@ -120,8 +131,9 @@ fun AppSidebar(
MillenniumSidebarGroupContent { MillenniumSidebarGroupContent {
MillenniumSidebarMenu { MillenniumSidebarMenu {
entry.items.forEach { item -> entry.items.forEach { item ->
val selected = navigationState.topLevelRoute == item.route && val selected =
(item.route != Routes.Library || currentLibraryTab == item.data) navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
SidebarNavItem( SidebarNavItem(
item = item, item = item,
selected = selected, selected = selected,
@ -154,13 +166,15 @@ fun AppSidebar(
label = supportLabel, label = supportLabel,
onClick = { openSupport() }, onClick = { openSupport() },
contentDescription = supportLabel, contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2) icon = Iconsax.IconsaxHeart2
)
MillenniumSidebarItem( MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title), label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) }, onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings, selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title), contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2) icon = Iconsax.IconsaxSetting2
)
} }
} }
} }
@ -169,11 +183,13 @@ fun AppSidebar(
private fun SidebarNavItem( private fun SidebarNavItem(
item: NavigationItem.Tab<*>, item: NavigationItem.Tab<*>,
selected: Boolean, selected: Boolean,
onClick: () -> Unit) { onClick: () -> Unit
) {
MillenniumSidebarItem( MillenniumSidebarItem(
label = item.title, label = item.title,
onClick = onClick, onClick = onClick,
selected = selected, selected = selected,
contentDescription = item.title, contentDescription = item.title,
icon = item.icon) icon = item.icon
)
} }