mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(search): enhance search functionality with recent search suggestions and improved UI components
This commit is contained in:
parent
93dfea9a30
commit
1ccd70a18c
@ -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>
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user