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.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) {

View File

@ -163,7 +163,7 @@ val sharedModules = module {
}
singleOf(::AudioPlayerQueueRepository)
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get())
DeviceAudioPlayerQueue(get(), get(), get(), get())
}
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))
.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),
)

View File

@ -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

View File

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

View File

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

View File

@ -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,
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
)
},
onClick = onClearAllRecentSearches,
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 = {
itemContent = { entry, isSelected ->
when (entry) {
is SearchDropdownEntry.ClearAll -> {
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(
imageVector = Iconsax.IconsaxTrash,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Text(
search,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
"Clear all history",
color = MaterialTheme.colorScheme.error,
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(
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
)
)
}
}
}
}
}
}
},
)
}
}

View File

@ -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,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
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 {
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,
)
}
}
}