mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
6 Commits
36819165da
...
ccfd28f4a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfd28f4a5 | ||
|
|
6d8b9a575f | ||
|
|
d7fa3d926d | ||
|
|
a869835628 | ||
|
|
15790d63a6 | ||
|
|
7c01b2c7c3 |
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -36,12 +37,14 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class DeviceAudioPlayerQueue(
|
||||
private val audioPlayer: AudioPlayer,
|
||||
private val settingsViewModel: SettingsViewModel,
|
||||
private val repository: AudioPlayerQueueRepository,
|
||||
private val pluginManager: PluginManager,
|
||||
) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
@ -98,6 +101,27 @@ class DeviceAudioPlayerQueue(
|
||||
persistQueueStateIfReady()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.playlistFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
) { currentMedia, playlist, loopState ->
|
||||
Triple(currentMedia, playlist, loopState)
|
||||
}.collect { (currentMedia, playlist, loopState) ->
|
||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||
if (loopState == LoopState.ALL) return@collect
|
||||
|
||||
val currentIndex = playlist.indexOfFirst { it.url == currentMedia.url }
|
||||
if (currentIndex < 0) return@collect
|
||||
|
||||
val tracksAfterCurrent = playlist.size - currentIndex - 1
|
||||
if (tracksAfterCurrent <= 0) {
|
||||
handleQueueCompletion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
@ -281,6 +305,61 @@ class DeviceAudioPlayerQueue(
|
||||
return collectionHistoryState.value
|
||||
}
|
||||
|
||||
private var isFetchingRecommendations = false
|
||||
|
||||
private suspend fun handleQueueCompletion() {
|
||||
if (isFetchingRecommendations) return
|
||||
|
||||
val settings = settingsViewModel.settingsState.value ?: return
|
||||
if (!settings.enableEndlessPlayback) return
|
||||
|
||||
val queue = queueFlow.value
|
||||
val streamingTracks = queue.filterIsInstance<QueueEntry.StreamingTrack>()
|
||||
if (streamingTracks.isEmpty()) return
|
||||
|
||||
val seedTracks = if (streamingTracks.size <= 5) {
|
||||
streamingTracks
|
||||
} else {
|
||||
val shuffled = streamingTracks.shuffled(Random)
|
||||
shuffled.take(5)
|
||||
}
|
||||
|
||||
val seedTrackIds = seedTracks.map { it.track.id }
|
||||
if (seedTrackIds.isEmpty()) return
|
||||
|
||||
isFetchingRecommendations = true
|
||||
logger.i { "Endless playback: fetching recommendations with ${seedTrackIds.size} seed tracks" }
|
||||
|
||||
val metadataService = pluginManager.selectedMetadataPlugin.value ?: run {
|
||||
logger.w { "Endless playback: no metadata plugin available" }
|
||||
isFetchingRecommendations = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val recommendations = metadataService.use {
|
||||
metadataTrackAPI.recommendationsBasedOnTracks(seedTrackIds, limit = 20)
|
||||
}
|
||||
|
||||
if (recommendations.isEmpty()) {
|
||||
logger.w { "Endless playback: no recommendations returned" }
|
||||
isFetchingRecommendations = false
|
||||
return
|
||||
}
|
||||
|
||||
val newEntries = recommendations.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
|
||||
logger.i { "Endless playback: adding ${newEntries.size} recommended tracks to queue" }
|
||||
addAllToQueue(newEntries)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Endless playback: failed to fetch recommendations" }
|
||||
} finally {
|
||||
isFetchingRecommendations = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restorePersistedState() {
|
||||
val state = repository.getPersistedState()
|
||||
if (state == null) {
|
||||
|
||||
@ -163,7 +163,7 @@ val sharedModules = module {
|
||||
}
|
||||
singleOf(::AudioPlayerQueueRepository)
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get())
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get())
|
||||
}
|
||||
|
||||
factory { (tag: String?) ->
|
||||
|
||||
@ -0,0 +1,481 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
|
||||
private val AutocompleteTextFieldShape = RoundedCornerShape(14.dp)
|
||||
private val AutocompleteMenuShape = RoundedCornerShape(12.dp)
|
||||
private val AutocompleteTextFieldMinHeight = 44.dp
|
||||
private val AutocompleteMenuDefaultMaxHeight = 300.dp
|
||||
private val AutocompleteMenuDefaultOffset = 4.dp
|
||||
|
||||
@Composable
|
||||
fun <T> AutocompleteTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
items: List<T>,
|
||||
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onItemSelected: (T) -> Unit = {},
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
singleLine: Boolean = true,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
isError: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
cursorBrush: Color = MaterialTheme.colorScheme.primary,
|
||||
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
|
||||
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
|
||||
menuOffset: Dp = AutocompleteMenuDefaultOffset,
|
||||
menuShape: Shape = AutocompleteMenuShape,
|
||||
menuShadowElevation: Dp = 8.dp,
|
||||
menuContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val colors = rememberButtonColors()
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
|
||||
var isMenuOpen by remember { mutableStateOf(false) }
|
||||
var isMovingFocusToPopup by remember { mutableStateOf(false) }
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val menuListState = rememberLazyListState()
|
||||
val popupFocusRequester = remember { FocusRequester() }
|
||||
val textFieldFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
||||
|
||||
LaunchedEffect(isFocused) {
|
||||
if (isFocused) {
|
||||
isMenuOpen = true
|
||||
isMovingFocusToPopup = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(value) {
|
||||
if (selectedIndex != -1) selectedIndex = -1
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedIndex) {
|
||||
if (selectedIndex >= 0 && menuExpanded) {
|
||||
menuListState.scrollToItem(selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
if (menuExpanded && selectedIndex in items.indices) {
|
||||
onItemSelected(items[selectedIndex])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
} else {
|
||||
keyboardActions.onSearch?.invoke(this)
|
||||
}
|
||||
},
|
||||
onDone = keyboardActions.onDone,
|
||||
onGo = keyboardActions.onGo,
|
||||
onNext = keyboardActions.onNext,
|
||||
onPrevious = keyboardActions.onPrevious,
|
||||
onSend = keyboardActions.onSend,
|
||||
)
|
||||
}
|
||||
|
||||
val gradient = outlinedGradient(colors, false)
|
||||
val border = when {
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
isFocused -> MaterialTheme.colorScheme.primary
|
||||
isHovered -> colors.border.copy(alpha = 0.85f)
|
||||
else -> colors.border
|
||||
}
|
||||
|
||||
val contentColor = when {
|
||||
!enabled -> colors.onContainer.copy(alpha = 0.38f)
|
||||
else -> colors.onContainer
|
||||
}
|
||||
|
||||
fun navigate(delta: Int) {
|
||||
if (items.isEmpty()) return
|
||||
selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = label != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
label?.invoke()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { textFieldSize = it }
|
||||
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
|
||||
.then(textFieldShadow(AutocompleteTextFieldShape, isFocused, colors))
|
||||
.clip(AutocompleteTextFieldShape)
|
||||
.background(gradient, AutocompleteTextFieldShape)
|
||||
.border(BorderStroke(.5.dp, border), AutocompleteTextFieldShape)
|
||||
.drawWithCache {
|
||||
val highlightBrush = Brush.verticalGradient(
|
||||
colors = listOf(colors.highlight, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = highlightBrush,
|
||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||
size = size,
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
leadingIcon()
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
)
|
||||
.focusRequester(textFieldFocusRequester)
|
||||
.onFocusChanged { state ->
|
||||
if (!state.isFocused && !isMovingFocusToPopup) {
|
||||
isMenuOpen = false
|
||||
selectedIndex = -1
|
||||
}
|
||||
}
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (onKeyEvent?.invoke(event) == true) {
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
if (!menuExpanded) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> {
|
||||
isMovingFocusToPopup = true
|
||||
popupFocusRequester.requestFocus()
|
||||
navigate(1)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
if (selectedIndex >= 0) {
|
||||
navigate(-1)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
if (selectedIndex in items.indices) {
|
||||
onItemSelected(items[selectedIndex])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
Key.Escape -> {
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = textStyle.copy(color = contentColor),
|
||||
cursorBrush = SolidColor(cursorBrush),
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = wrappedKeyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = { innerTextField ->
|
||||
if (value.isEmpty() && placeholder != null && !isFocused) {
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (menuExpanded && textFieldSize.width > 0) {
|
||||
val textFieldHeightPx = textFieldSize.height
|
||||
val menuOffsetPx = with(density) { menuOffset.roundToPx() }
|
||||
val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
||||
properties = PopupProperties(focusable = true),
|
||||
onDismissRequest = {
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(textFieldWidthDp)
|
||||
.heightIn(max = menuMaxHeight)
|
||||
.shadow(menuShadowElevation, menuShape)
|
||||
.background(menuContainerColor, menuShape)
|
||||
.clip(menuShape)
|
||||
.focusRequester(popupFocusRequester)
|
||||
.focusable()
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) {
|
||||
isMovingFocusToPopup = false
|
||||
}
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> {
|
||||
navigate(1); true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
navigate(-1); true
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
if (selectedIndex in items.indices) {
|
||||
onItemSelected(items[selectedIndex])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
textFieldFocusRequester.requestFocus()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
Key.Escape -> {
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
textFieldFocusRequester.requestFocus()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
LazyColumn(
|
||||
state = menuListState,
|
||||
modifier = Modifier
|
||||
.heightIn(max = menuMaxHeight),
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onItemSelected(items[index])
|
||||
selectedIndex = -1
|
||||
isMenuOpen = false
|
||||
textFieldFocusRequester.requestFocus()
|
||||
},
|
||||
) {
|
||||
itemContent(items[index], selectedIndex == index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AutocompleteTextFieldPreview() {
|
||||
MaterialTheme {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
val artists = remember {
|
||||
listOf(
|
||||
"Twenty One Pilots",
|
||||
"The Beatles",
|
||||
"Adele",
|
||||
"Drake",
|
||||
"Taylor Swift",
|
||||
"Arctic Monkeys",
|
||||
"Billie Eilish",
|
||||
)
|
||||
}
|
||||
var value by remember { mutableStateOf("") }
|
||||
|
||||
AutocompleteTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
},
|
||||
items = artists.filter {
|
||||
it.contains(value, ignoreCase = true) && value.isNotEmpty()
|
||||
},
|
||||
onItemSelected = { selected ->
|
||||
value = selected
|
||||
},
|
||||
placeholder = { Text("Search artists...") },
|
||||
leadingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
itemContent = { item, isSelected ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = item,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -255,7 +255,7 @@ fun OutlineButton(
|
||||
.then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered))
|
||||
.clip(shape)
|
||||
.background(gradient, shape)
|
||||
.border(BorderStroke(1.5.dp, border), shape)
|
||||
.border(BorderStroke(0.5.dp, border), shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
@ -312,7 +312,7 @@ fun PrimaryButton(
|
||||
.then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered))
|
||||
.clip(shape)
|
||||
.background(gradient, shape)
|
||||
.border(BorderStroke(1.5.dp, colors.accent), shape)
|
||||
.border(BorderStroke(0.5.dp, colors.accent), shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
@ -372,7 +372,7 @@ fun SecondaryButton(
|
||||
.clip(shape)
|
||||
.background(gradient, shape)
|
||||
.border(
|
||||
BorderStroke(1.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
|
||||
BorderStroke(0.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
|
||||
shape,
|
||||
)
|
||||
.clickable(
|
||||
@ -582,7 +582,7 @@ fun ButtonGroup(
|
||||
.then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)),
|
||||
shape = GroupShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.5.dp, colors.border),
|
||||
border = BorderStroke(0.5.dp, colors.border),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.background(outlinedGradient(colors, false), GroupShape),
|
||||
@ -599,7 +599,7 @@ fun ButtonGroupDivider() {
|
||||
val colors = rememberButtonColors()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.5.dp)
|
||||
.width(1.dp)
|
||||
.heightIn(min = 20.dp)
|
||||
.background(colors.border),
|
||||
)
|
||||
|
||||
@ -96,7 +96,7 @@ fun CheckBox(
|
||||
isFilled -> colors.accent
|
||||
else -> colors.border
|
||||
}
|
||||
val borderWidth = if (isFilled) 1.5.dp else 1.5.dp
|
||||
val borderWidth = 0.5.dp
|
||||
|
||||
val shadowElevation = when {
|
||||
isPressed -> 1.dp
|
||||
|
||||
@ -65,7 +65,7 @@ private val TextFieldShape = RoundedCornerShape(14.dp)
|
||||
private val TextFieldMinHeight = 44.dp
|
||||
|
||||
@Composable
|
||||
private fun textFieldShadow(
|
||||
internal fun textFieldShadow(
|
||||
shape: androidx.compose.ui.graphics.Shape,
|
||||
focused: Boolean,
|
||||
colors: ButtonColors,
|
||||
@ -142,7 +142,7 @@ fun TextField(
|
||||
.then(textFieldShadow(TextFieldShape, isFocused, colors))
|
||||
.clip(TextFieldShape)
|
||||
.background(gradient, TextFieldShape)
|
||||
.border(BorderStroke(1.5.dp, border), TextFieldShape)
|
||||
.border(BorderStroke(0.5.dp, border), TextFieldShape)
|
||||
.drawWithCache {
|
||||
val highlightBrush = Brush.verticalGradient(
|
||||
colors = listOf(colors.highlight, Color.Transparent),
|
||||
|
||||
@ -272,8 +272,8 @@ private fun HomeSection(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = subtitle ?: "",
|
||||
if (!subtitle.isNullOrEmpty()) Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
||||
@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@ -50,8 +49,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -60,30 +57,20 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.X
|
||||
@ -96,12 +83,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackList
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
@ -122,8 +109,6 @@ import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
private val GridMinCellSize = 180.dp
|
||||
private val SearchFieldShape = RoundedCornerShape(6.dp)
|
||||
private val TabShape = RoundedCornerShape(6.dp)
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
@ -135,10 +120,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
|
||||
var isSearchFocused by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var keyboardSelectedIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
fun playSingleTrack(track: MetadataTrack) {
|
||||
scope.launch {
|
||||
@ -242,30 +224,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
val showRecentSearches =
|
||||
isSearchFocused && state.query.isBlank() && state.recentSearches.isNotEmpty()
|
||||
val dropdownItems = if (showRecentSearches) state.recentSearches else emptyList()
|
||||
val totalDropdownItems = if (showRecentSearches) dropdownItems.size + 1 else 0
|
||||
|
||||
fun navigateDropdown(delta: Int) {
|
||||
if (totalDropdownItems == 0) return
|
||||
keyboardSelectedIndex =
|
||||
(keyboardSelectedIndex + delta + totalDropdownItems) % totalDropdownItems
|
||||
}
|
||||
|
||||
fun selectDropdownItem() {
|
||||
if (keyboardSelectedIndex < 0 || !showRecentSearches) return
|
||||
if (keyboardSelectedIndex == 0) {
|
||||
viewModel.clearAllRecentSearches()
|
||||
} else {
|
||||
val item = dropdownItems[keyboardSelectedIndex - 1]
|
||||
viewModel.applyRecentSearch(item)
|
||||
}
|
||||
keyboardSelectedIndex = -1
|
||||
isSearchFocused = false
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { ApplicationMainBar(backButton = false) }
|
||||
) { innerPadding ->
|
||||
@ -279,60 +237,19 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = {
|
||||
viewModel.onQueryChange(it)
|
||||
keyboardSelectedIndex = -1
|
||||
},
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
onClear = viewModel::clearQuery,
|
||||
isFocused = isSearchFocused,
|
||||
onFocusChanged = { isSearchFocused = it },
|
||||
focusRequester = focusRequester,
|
||||
onKeyEvent = { event ->
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> {
|
||||
navigateDropdown(1); true
|
||||
}
|
||||
|
||||
Key.DirectionUp -> {
|
||||
navigateDropdown(-1); true
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
selectDropdownItem(); true
|
||||
}
|
||||
|
||||
Key.Escape -> {
|
||||
isSearchFocused = false
|
||||
keyboardSelectedIndex = -1
|
||||
focusManager.clearFocus()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
onSearch = {
|
||||
focusManager.clearFocus()
|
||||
isSearchFocused = false
|
||||
},
|
||||
showDropdown = showRecentSearches,
|
||||
onDismissDropdown = {
|
||||
isSearchFocused = false
|
||||
keyboardSelectedIndex = -1
|
||||
},
|
||||
recentSearches = state.recentSearches,
|
||||
keyboardSelectedIndex = keyboardSelectedIndex,
|
||||
onRecentSearchClick = { search ->
|
||||
viewModel.applyRecentSearch(search)
|
||||
isSearchFocused = false
|
||||
keyboardSelectedIndex = -1
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
onRecentSearchRemove = viewModel::removeRecentSearch,
|
||||
onClearAllRecentSearches = {
|
||||
viewModel.clearAllRecentSearches()
|
||||
isSearchFocused = false
|
||||
keyboardSelectedIndex = -1
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
)
|
||||
@ -443,35 +360,57 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
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 SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
isFocused: Boolean,
|
||||
onFocusChanged: (Boolean) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> Boolean,
|
||||
onSearch: () -> Unit,
|
||||
showDropdown: Boolean,
|
||||
onDismissDropdown: () -> Unit,
|
||||
recentSearches: List<String>,
|
||||
keyboardSelectedIndex: Int,
|
||||
onRecentSearchClick: (String) -> Unit,
|
||||
onRecentSearchRemove: (String) -> Unit,
|
||||
onClearAllRecentSearches: () -> Unit,
|
||||
) {
|
||||
val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty()
|
||||
val dropdownItems = 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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
TextField(
|
||||
AutocompleteTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
items = dropdownItems,
|
||||
onItemSelected = { entry -> entry.onClick() },
|
||||
placeholder = {
|
||||
Text(
|
||||
"Search songs, artists, albums...",
|
||||
@ -501,77 +440,77 @@ private fun SearchBar(
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { onFocusChanged(it.isFocused) }
|
||||
.focusRequester(focusRequester)
|
||||
.onKeyEvent(onKeyEvent),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = onDismissDropdown,
|
||||
itemContent = { entry, isSelected ->
|
||||
when (entry) {
|
||||
is SearchDropdownEntry.ClearAll -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp),
|
||||
offset = DpOffset(x = 0.dp, y = 4.dp),
|
||||
properties = PopupProperties(focusable = false),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Column {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Clear all history",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.surfaceVariant
|
||||
else Color.Transparent
|
||||
)
|
||||
},
|
||||
onClick = onClearAllRecentSearches,
|
||||
leadingIcon = {
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
},
|
||||
Text(
|
||||
"Clear all history",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SearchDropdownEntry.RecentSearch -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
if (keyboardSelectedIndex == 0) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.surface
|
||||
if (isSelected) MaterialTheme.colorScheme.surfaceVariant
|
||||
else Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
recentSearches.forEachIndexed { index, search ->
|
||||
val isSelected = keyboardSelectedIndex == index + 1
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
search,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.InconsaxClock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Text(
|
||||
entry.query,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = { onRecentSearchRemove(search) },
|
||||
onClick = entry.onRemove,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
@ -581,20 +520,12 @@ private fun SearchBar(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { onRecentSearchClick(search) },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,12 +21,20 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -38,20 +46,30 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.buttonShadow
|
||||
import dev.krtirtho.spotube.core.ui.base.outlinedGradient
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberButtonColors
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
@ -156,20 +174,7 @@ fun SidebarItem(
|
||||
onClick: () -> Unit,
|
||||
showDownloadBadge: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(
|
||||
if (selected) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = if (expanded) Arrangement.Start else Arrangement.Center
|
||||
) {
|
||||
val itemContent: @Composable RowScope.() -> Unit = {
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = activeIcon,
|
||||
@ -178,7 +183,7 @@ fun SidebarItem(
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
},
|
||||
)
|
||||
if (showDownloadBadge) {
|
||||
DownloadBadgeIndicator(
|
||||
@ -187,17 +192,89 @@ fun SidebarItem(
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(if (expanded) 1f else 0f))
|
||||
}
|
||||
|
||||
val buttonModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
val contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)
|
||||
|
||||
if (selected) {
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
contentPadding = contentPadding,
|
||||
content = itemContent,
|
||||
)
|
||||
} else {
|
||||
val colors = rememberButtonColors()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val gradient = outlinedGradient(colors, isPressed)
|
||||
val border = colors.border.copy(alpha = if (isPressed) 0.7f else 1f)
|
||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
|
||||
Box(
|
||||
modifier = buttonModifier
|
||||
.hoverable(interactionSource = interactionSource)
|
||||
.graphicsLayer { translationY = lift.toPx() }
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (isHovered || isPressed) {
|
||||
buttonShadow(
|
||||
shape,
|
||||
isPressed,
|
||||
primary = false,
|
||||
colors,
|
||||
hovered = isHovered
|
||||
).background(gradient, shape)
|
||||
.border(BorderStroke(0.5.dp, border), shape)
|
||||
.drawWithCache {
|
||||
val highlightBrush = Brush.verticalGradient(
|
||||
colors = listOf(colors.highlight, Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = highlightBrush,
|
||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||
size = size,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(contentPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = itemContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user