Compare commits

..

5 Commits

9 changed files with 770 additions and 202 deletions

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -36,12 +37,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class DeviceAudioPlayerQueue( class DeviceAudioPlayerQueue(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
private val settingsViewModel: SettingsViewModel, private val settingsViewModel: SettingsViewModel,
private val repository: AudioPlayerQueueRepository, private val repository: AudioPlayerQueueRepository,
private val pluginManager: PluginManager,
) : AudioPlayerQueue, KoinComponent { ) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -98,6 +101,27 @@ class DeviceAudioPlayerQueue(
persistQueueStateIfReady() 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( override suspend fun load(
@ -281,6 +305,61 @@ class DeviceAudioPlayerQueue(
return collectionHistoryState.value 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() { private suspend fun restorePersistedState() {
val state = repository.getPersistedState() val state = repository.getPersistedState()
if (state == null) { if (state == null) {

View File

@ -163,7 +163,7 @@ val sharedModules = module {
} }
singleOf(::AudioPlayerQueueRepository) singleOf(::AudioPlayerQueueRepository)
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get())
} }
factory { (tag: String?) -> factory { (tag: String?) ->

View File

@ -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,
)
}
},
)
}
}
}

View File

@ -255,7 +255,7 @@ fun OutlineButton(
.then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered)) .then(buttonShadow(shape, isPressed, primary = false, colors, hovered = isHovered))
.clip(shape) .clip(shape)
.background(gradient, shape) .background(gradient, shape)
.border(BorderStroke(1.5.dp, border), shape) .border(BorderStroke(0.5.dp, border), shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -312,7 +312,7 @@ fun PrimaryButton(
.then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered)) .then(buttonShadow(shape, isPressed, primary = true, colors, hovered = isHovered))
.clip(shape) .clip(shape)
.background(gradient, shape) .background(gradient, shape)
.border(BorderStroke(1.5.dp, colors.accent), shape) .border(BorderStroke(0.5.dp, colors.accent), shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -372,7 +372,7 @@ fun SecondaryButton(
.clip(shape) .clip(shape)
.background(gradient, shape) .background(gradient, shape)
.border( .border(
BorderStroke(1.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)), BorderStroke(0.5.dp, colors.secondaryContainer.copy(alpha = 0.5f)),
shape, shape,
) )
.clickable( .clickable(
@ -582,7 +582,7 @@ fun ButtonGroup(
.then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)), .then(buttonShadow(GroupShape, pressed = false, primary = false, colors, hovered = false)),
shape = GroupShape, shape = GroupShape,
color = Color.Transparent, color = Color.Transparent,
border = BorderStroke(1.5.dp, colors.border), border = BorderStroke(0.5.dp, colors.border),
) { ) {
Box( Box(
modifier = Modifier.background(outlinedGradient(colors, false), GroupShape), modifier = Modifier.background(outlinedGradient(colors, false), GroupShape),
@ -599,7 +599,7 @@ fun ButtonGroupDivider() {
val colors = rememberButtonColors() val colors = rememberButtonColors()
Box( Box(
modifier = Modifier modifier = Modifier
.width(1.5.dp) .width(1.dp)
.heightIn(min = 20.dp) .heightIn(min = 20.dp)
.background(colors.border), .background(colors.border),
) )

View File

@ -96,7 +96,7 @@ fun CheckBox(
isFilled -> colors.accent isFilled -> colors.accent
else -> colors.border else -> colors.border
} }
val borderWidth = if (isFilled) 1.5.dp else 1.5.dp val borderWidth = 0.5.dp
val shadowElevation = when { val shadowElevation = when {
isPressed -> 1.dp isPressed -> 1.dp

View File

@ -65,7 +65,7 @@ private val TextFieldShape = RoundedCornerShape(14.dp)
private val TextFieldMinHeight = 44.dp private val TextFieldMinHeight = 44.dp
@Composable @Composable
private fun textFieldShadow( internal fun textFieldShadow(
shape: androidx.compose.ui.graphics.Shape, shape: androidx.compose.ui.graphics.Shape,
focused: Boolean, focused: Boolean,
colors: ButtonColors, colors: ButtonColors,
@ -142,7 +142,7 @@ fun TextField(
.then(textFieldShadow(TextFieldShape, isFocused, colors)) .then(textFieldShadow(TextFieldShape, isFocused, colors))
.clip(TextFieldShape) .clip(TextFieldShape)
.background(gradient, TextFieldShape) .background(gradient, TextFieldShape)
.border(BorderStroke(1.5.dp, border), TextFieldShape) .border(BorderStroke(0.5.dp, border), TextFieldShape)
.drawWithCache { .drawWithCache {
val highlightBrush = Brush.verticalGradient( val highlightBrush = Brush.verticalGradient(
colors = listOf(colors.highlight, Color.Transparent), colors = listOf(colors.highlight, Color.Transparent),

View File

@ -272,8 +272,8 @@ private fun HomeSection(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text( if (!subtitle.isNullOrEmpty()) Text(
text = subtitle ?: "", text = subtitle,
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View File

@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.LinearProgressIndicator 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -60,30 +57,20 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer 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.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.X 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.share.ShareService 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.base.ChipTab
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay 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.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
@ -122,8 +109,6 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
private val GridMinCellSize = 180.dp private val GridMinCellSize = 180.dp
private val SearchFieldShape = RoundedCornerShape(6.dp)
private val TabShape = RoundedCornerShape(6.dp)
@Composable @Composable
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
@ -135,10 +120,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
var isSearchFocused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var keyboardSelectedIndex by remember { mutableIntStateOf(-1) }
fun playSingleTrack(track: MetadataTrack) { fun playSingleTrack(track: MetadataTrack) {
scope.launch { 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( Scaffold(
topBar = { ApplicationMainBar(backButton = false) } topBar = { ApplicationMainBar(backButton = false) }
) { innerPadding -> ) { innerPadding ->
@ -279,60 +237,19 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
) { ) {
SearchBar( SearchBar(
query = state.query, query = state.query,
onQueryChange = { onQueryChange = viewModel::onQueryChange,
viewModel.onQueryChange(it)
keyboardSelectedIndex = -1
},
onClear = viewModel::clearQuery, 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 = { onSearch = {
focusManager.clearFocus() focusManager.clearFocus()
isSearchFocused = false
},
showDropdown = showRecentSearches,
onDismissDropdown = {
isSearchFocused = false
keyboardSelectedIndex = -1
}, },
recentSearches = state.recentSearches, recentSearches = state.recentSearches,
keyboardSelectedIndex = keyboardSelectedIndex,
onRecentSearchClick = { search -> onRecentSearchClick = { search ->
viewModel.applyRecentSearch(search) viewModel.applyRecentSearch(search)
isSearchFocused = false
keyboardSelectedIndex = -1
focusManager.clearFocus() focusManager.clearFocus()
}, },
onRecentSearchRemove = viewModel::removeRecentSearch, onRecentSearchRemove = viewModel::removeRecentSearch,
onClearAllRecentSearches = { onClearAllRecentSearches = {
viewModel.clearAllRecentSearches() viewModel.clearAllRecentSearches()
isSearchFocused = false
keyboardSelectedIndex = -1
focusManager.clearFocus() 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 @Composable
private fun SearchBar( private fun SearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onClear: () -> Unit, onClear: () -> Unit,
isFocused: Boolean,
onFocusChanged: (Boolean) -> Unit,
focusRequester: FocusRequester,
onKeyEvent: (androidx.compose.ui.input.key.KeyEvent) -> Boolean,
onSearch: () -> Unit, onSearch: () -> Unit,
showDropdown: Boolean,
onDismissDropdown: () -> Unit,
recentSearches: List<String>, recentSearches: List<String>,
keyboardSelectedIndex: Int,
onRecentSearchClick: (String) -> Unit, onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit, onRecentSearchRemove: (String) -> Unit,
onClearAllRecentSearches: () -> 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 8.dp) .padding(bottom = 8.dp)
) { ) {
TextField( AutocompleteTextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
singleLine = true, items = dropdownItems,
maxLines = 1, onItemSelected = { entry -> entry.onClick() },
placeholder = { placeholder = {
Text( Text(
"Search songs, artists, albums...", "Search songs, artists, albums...",
@ -501,77 +440,77 @@ private fun SearchBar(
} }
} }
}, },
modifier = Modifier singleLine = true,
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.onFocusChanged { onFocusChanged(it.isFocused) }
.focusRequester(focusRequester)
.onKeyEvent(onKeyEvent),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }), keyboardActions = KeyboardActions(onSearch = { onSearch() }),
) itemContent = { entry, isSelected ->
when (entry) {
DropdownMenu( is SearchDropdownEntry.ClearAll -> {
expanded = showDropdown, Box(
onDismissRequest = onDismissDropdown, modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(horizontal = 4.dp, vertical = 2.dp)
.heightIn(max = 300.dp), .clip(RoundedCornerShape(4.dp))
offset = DpOffset(x = 0.dp, y = 4.dp), .background(
properties = PopupProperties(focusable = false), if (isSelected) MaterialTheme.colorScheme.surfaceVariant
containerColor = MaterialTheme.colorScheme.surface, else Color.Transparent
shape = RoundedCornerShape(8.dp), )
shadowElevation = 8.dp, ) {
) { Row(
Box(modifier = Modifier.padding(vertical = 4.dp)) { modifier = Modifier
Column { .fillMaxWidth()
DropdownMenuItem( .padding(horizontal = 12.dp, vertical = 10.dp),
text = { verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.spacedBy(10.dp),
"Clear all history", ) {
color = MaterialTheme.colorScheme.error, Icon(
style = MaterialTheme.typography.bodyMedium imageVector = Iconsax.IconsaxTrash,
) contentDescription = null,
}, tint = MaterialTheme.colorScheme.error,
onClick = onClearAllRecentSearches, modifier = Modifier.size(16.dp)
leadingIcon = { )
Icon(
imageVector = Iconsax.IconsaxTrash,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
},
modifier = Modifier
.padding(horizontal = 4.dp, vertical = 2.dp)
.clip(RoundedCornerShape(4.dp))
.background(
if (keyboardSelectedIndex == 0) MaterialTheme.colorScheme.surfaceVariant
else MaterialTheme.colorScheme.surface
)
)
recentSearches.forEachIndexed { index, search ->
val isSelected = keyboardSelectedIndex == index + 1
DropdownMenuItem(
text = {
Text( Text(
search, "Clear all history",
maxLines = 1, color = MaterialTheme.colorScheme.error,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
}, }
leadingIcon = { }
}
is SearchDropdownEntry.RecentSearch -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp)
.clip(RoundedCornerShape(4.dp))
.background(
if (isSelected) MaterialTheme.colorScheme.surfaceVariant
else Color.Transparent
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon( Icon(
imageVector = Iconsax.InconsaxClock, imageVector = Iconsax.InconsaxClock,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
}, Text(
trailingIcon = { entry.query,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton( IconButton(
onClick = { onRecentSearchRemove(search) }, onClick = entry.onRemove,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) { ) {
Icon( Icon(
@ -581,20 +520,12 @@ private fun SearchBar(
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) 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
)
)
} }
} }
} },
} )
} }
} }

View File

@ -21,12 +21,20 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.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.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector 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.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes 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.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
@ -156,20 +174,7 @@ fun SidebarItem(
onClick: () -> Unit, onClick: () -> Unit,
showDownloadBadge: Boolean = false, showDownloadBadge: Boolean = false,
) { ) {
Row( val itemContent: @Composable RowScope.() -> Unit = {
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
) {
Box(modifier = Modifier.size(24.dp)) { Box(modifier = Modifier.size(24.dp)) {
Icon( Icon(
imageVector = activeIcon, imageVector = activeIcon,
@ -178,7 +183,7 @@ fun SidebarItem(
MaterialTheme.colorScheme.onSecondaryContainer MaterialTheme.colorScheme.onSecondaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} },
) )
if (showDownloadBadge) { if (showDownloadBadge) {
DownloadBadgeIndicator( DownloadBadgeIndicator(
@ -187,17 +192,89 @@ fun SidebarItem(
} }
} }
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Row(verticalAlignment = Alignment.CenterVertically) { Text(
Spacer(modifier = Modifier.width(12.dp)) text = label,
Text( maxLines = 1,
text = label, softWrap = false,
color = if (selected) { color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer 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 { } else {
MaterialTheme.colorScheme.onSurfaceVariant Modifier
} }
) )
} .clickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
)
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
content = itemContent,
)
} }
} }
} }