diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index 3b36a018..108b2ee2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -238,9 +238,10 @@ fun OutlineButton( shape: androidx.compose.ui.graphics.Shape = ButtonShape, contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), hoverOnly: Boolean = false, + colors: ButtonColors? = null, content: @Composable RowScope.() -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isHovered by interactionSource.collectIsHoveredAsState() @@ -306,9 +307,10 @@ fun PrimaryButton( enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), + colors: ButtonColors? = null, content: @Composable RowScope.() -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isHovered by interactionSource.collectIsHoveredAsState() @@ -365,9 +367,10 @@ fun SecondaryButton( enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), + colors: ButtonColors? = null, content: @Composable RowScope.() -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isHovered by interactionSource.collectIsHoveredAsState() @@ -426,6 +429,7 @@ fun IconButton( modifier: Modifier = Modifier, enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { OutlineButton( @@ -434,10 +438,12 @@ fun IconButton( enabled = enabled, shape = shape, contentPadding = PaddingValues(8.dp), + colors = colors, ) { content() } } + @Composable fun GhostIconButton( @@ -445,6 +451,7 @@ fun GhostIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { OutlineButton( @@ -454,6 +461,7 @@ fun GhostIconButton( shape = shape, contentPadding = PaddingValues(8.dp), hoverOnly = true, + colors = colors, ) { content() } @@ -465,6 +473,7 @@ fun PrimaryIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { PrimaryButton( @@ -473,6 +482,7 @@ fun PrimaryIconButton( enabled = enabled, shape = shape, contentPadding = PaddingValues(8.dp), + colors = colors, ) { content() } @@ -484,6 +494,7 @@ fun SecondaryIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, shape: androidx.compose.ui.graphics.Shape = ButtonShape, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { SecondaryButton( @@ -492,6 +503,7 @@ fun SecondaryIconButton( enabled = enabled, shape = shape, contentPadding = PaddingValues(8.dp), + colors = colors, ) { content() } @@ -501,8 +513,9 @@ fun SecondaryIconButton( fun ButtonBadge( count: Int, modifier: Modifier = Modifier, + colors: ButtonColors? = null, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() Box( modifier = modifier .heightIn(min = 22.dp) @@ -529,9 +542,10 @@ fun GroupButton( modifier: Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 11.dp), + colors: ButtonColors? = null, content: @Composable RowScope.() -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isHovered by interactionSource.collectIsHoveredAsState() @@ -570,9 +584,10 @@ fun GroupIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val isHovered by interactionSource.collectIsHoveredAsState() @@ -604,9 +619,10 @@ fun GroupIconButton( @Composable fun ButtonGroup( modifier: Modifier = Modifier, + colors: ButtonColors? = null, content: @Composable () -> Unit, ) { - val colors = rememberButtonColors() + val colors = colors ?: rememberButtonColors() Surface( modifier = modifier .defaultMinSize(minHeight = ButtonMinHeight) @@ -634,8 +650,10 @@ fun ButtonGroup( } @Composable -fun ButtonGroupDivider() { - val colors = rememberButtonColors() +fun ButtonGroupDivider( + colors: ButtonColors? = null, +) { + val colors = colors ?: rememberButtonColors() Box( modifier = Modifier .width(1.dp) @@ -871,3 +889,59 @@ private fun ButtonStylesPreview() { } } } + +enum class VariableIconButtonVariant { + Outline, + Ghost, + Primary, + Secondary, +} + +@Composable +fun VariableIconButton( + variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: androidx.compose.ui.graphics.Shape = ButtonShape, + colors: ButtonColors? = null, + content: @Composable () -> Unit, +) { + when (variant) { + VariableIconButtonVariant.Outline -> IconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + content = content, + ) + + VariableIconButtonVariant.Ghost -> GhostIconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + content = content, + ) + + VariableIconButtonVariant.Primary -> PrimaryIconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + content = content, + ) + + VariableIconButtonVariant.Secondary -> SecondaryIconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + content = content, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt new file mode 100644 index 00000000..affee671 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt @@ -0,0 +1,299 @@ +/* + * 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 . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +private val SliderTrackHeight = 7.dp +private val SliderThumbSize = 24.dp +private val SliderMinHeight = 32.dp + +@Composable +fun Slider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onValueChangeFinished: () -> Unit = {}, +) { + val colors = rememberButtonColors() + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val density = LocalDensity.current + var sliderWidth by remember { mutableIntStateOf(0) } + + val range = valueRange.endInclusive - valueRange.start + val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f + + val trackActiveColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f) + val trackInactiveColor = if (enabled) { + colors.border.copy(alpha = 0.6f) + } else { + colors.onContainer.copy(alpha = 0.2f) + } + val thumbColor = if (enabled) colors.accent else colors.onContainer.copy(alpha = 0.38f) + + val thumbScale by animateFloatAsState( + targetValue = when { + isPressed -> 1.15f + isHovered -> 1.08f + else -> 1f + }, + animationSpec = tween(durationMillis = 150), + label = "thumbScale", + ) + + val thumbElevation = when { + isPressed -> 2.dp + isHovered -> 8.dp + else -> 5.dp + } + + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = SliderMinHeight) + .onSizeChanged { sliderWidth = it.width } + .hoverable(interactionSource = interactionSource, enabled = enabled) + .pointerInput(enabled, valueRange, steps) { + if (!enabled) return@pointerInput + + val stepSize = if (steps > 0) range / (steps + 1) else 0f + val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } + + fun valueFromX(x: Float): Float { + val width = size.width.toFloat() + val trackWidth = width - 2 * thumbHalfPx + if (trackWidth <= 0f) return valueRange.start + val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start + val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) + return if (steps > 0) { + val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() + (valueRange.start + stepIndex * stepSize).coerceIn( + valueRange.start, + valueRange.endInclusive, + ) + } else { + coerced + } + } + + detectTapGestures { offset -> + onValueChange(valueFromX(offset.x)) + onValueChangeFinished() + } + } + .pointerInput(enabled, valueRange, steps) { + if (!enabled) return@pointerInput + + val stepSize = if (steps > 0) range / (steps + 1) else 0f + val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } + + fun valueFromX(x: Float): Float { + val width = size.width.toFloat() + val trackWidth = width - 2 * thumbHalfPx + if (trackWidth <= 0f) return valueRange.start + val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start + val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) + return if (steps > 0) { + val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() + (valueRange.start + stepIndex * stepSize).coerceIn( + valueRange.start, + valueRange.endInclusive, + ) + } else { + coerced + } + } + + detectHorizontalDragGestures( + onDragEnd = { onValueChangeFinished() }, + ) { change, _ -> + change.consume() + onValueChange(valueFromX(change.position.x)) + } + }, + contentAlignment = Alignment.CenterStart, + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(SliderTrackHeight) + .align(Alignment.CenterStart), + ) { + val thumbHalfPx = SliderThumbSize.toPx() / 2f + val trackY = size.height / 2f + val trackStart = thumbHalfPx + val trackEnd = size.width - thumbHalfPx + val trackWidth = trackEnd - trackStart + + if (trackWidth > 0f) { + val trackRadius = CornerRadius(size.height / 2f) + + drawRoundRect( + brush = Brush.verticalGradient( + colors = listOf( + trackInactiveColor.copy(alpha = 0.45f), + trackInactiveColor, + trackInactiveColor.copy(alpha = 0.7f), + ), + ), + topLeft = Offset(trackStart, 0f), + size = Size(trackWidth, size.height), + cornerRadius = trackRadius, + ) + drawLine( + color = Color.White.copy(alpha = 0.15f), + start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), + end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round, + ) + + val activeEnd = trackStart + trackWidth * fraction + drawRoundRect( + brush = Brush.verticalGradient( + colors = listOf( + trackActiveColor.copy(alpha = 0.75f), + trackActiveColor, + trackActiveColor.copy(alpha = 0.9f), + ), + ), + topLeft = Offset(trackStart, 0f), + size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height), + cornerRadius = trackRadius, + ) + drawLine( + color = Color.White.copy(alpha = 0.22f), + start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), + end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()), + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round, + ) + + if (steps > 0 && enabled) { + val stepSpacing = trackWidth / (steps + 1) + for (i in 1 downTo steps) { + val x = trackStart + stepSpacing * i + drawCircle( + color = if (x <= activeEnd) { + colors.onAccent.copy(alpha = 0.5f) + } else { + colors.onContainer.copy(alpha = 0.25f) + }, + radius = 2.dp.toPx(), + center = Offset(x, trackY), + ) + } + } + } + } + + Box( + modifier = Modifier + .size(SliderThumbSize) + .offset { + val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } + val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx + val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx + IntOffset(x.roundToInt(), 0) + } + .graphicsLayer { + scaleX = thumbScale + scaleY = thumbScale + } + .shadow( + elevation = thumbElevation, + shape = CircleShape, + ambientColor = colors.accent.copy(alpha = 0.35f), + spotColor = colors.accent.copy(alpha = 0.5f), + ) + .background( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.6f), + thumbColor.copy(alpha = 0.95f), + thumbColor, + ), + center = Offset(0f, -0.55f), + radius = 1.1f, + ), + shape = CircleShape, + ) + .border( + width = 0.8.dp, + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.55f), + Color.White.copy(alpha = 0.08f), + ), + ), + shape = CircleShape, + ), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 90abd300..23173fa1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -45,11 +45,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -68,12 +66,16 @@ 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.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue @@ -81,42 +83,41 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.base.rememberButtonColors +import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon +import dev.krtirtho.spotube.modules.downloads.DownloadStatus +import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel -import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID +import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeatMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle +import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlin.time.Duration.Companion.milliseconds -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks -import dev.krtirtho.spotube.modules.saved_tracks.SavedState -import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon -import dev.krtirtho.spotube.modules.downloads.DownloadStatus -import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive -import dev.krtirtho.spotube.resources.iconsax.IconsaxCd -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight -import dev.krtirtho.spotube.resources.iconsax.InconsaxClock // The expanded player on small screens @@ -278,15 +279,18 @@ fun AppExpandedPlayer( snackbarHostState.showSnackbar("Already downloaded") } } + is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { download?.let { downloadsViewModel.retry(it.id) } scope.launch { snackbarHostState.showSnackbar("Retrying download...") } } + is DownloadStatus.Downloading, is DownloadStatus.Queued -> { // already in progress } + null -> { val track = currentTrack if (track != null) { @@ -393,7 +397,7 @@ fun AppExpandedPlayer( .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onCollapse) { + GhostIconButton(onClick = onCollapse) { Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") } Text( @@ -403,7 +407,7 @@ fun AppExpandedPlayer( textAlign = TextAlign.Center, maxLines = 1, ) - IconButton(onClick = { showMoreOptionsSheet = true }) { + GhostIconButton(onClick = { showMoreOptionsSheet = true }) { Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -462,7 +466,7 @@ fun AppExpandedPlayer( textAlign = TextAlign.Center, ) } - IconButton(onClick = onQueue) { + IconButton(onClick = onQueue, shape = CircleShape) { Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } } @@ -506,7 +510,7 @@ fun AppExpandedPlayer( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = ::onShuffleToggle) { + GhostIconButton(onClick = ::onShuffleToggle) { Icon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -517,28 +521,31 @@ fun AppExpandedPlayer( } ) } - IconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } - Surface( + IconButton( + onClick = ::onPlayPause, + colors = rememberButtonColors().copy( + containerDarker = MaterialTheme.colorScheme.onSurface, + containerLighter = MaterialTheme.colorScheme.onSurface, + containerPressed = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = Modifier + .size(72.dp), shape = CircleShape, - color = MaterialTheme.colorScheme.onSurface, - contentColor = MaterialTheme.colorScheme.surface, - shadowElevation = 8.dp, - modifier = Modifier.size(72.dp), ) { - IconButton(onClick = ::onPlayPause, modifier = Modifier.fillMaxSize()) { - Icon( - if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(30.dp), - ) - } + Icon( + if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(30.dp), + tint = MaterialTheme.colorScheme.surface, + ) } - IconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } - IconButton(onClick = ::onLoopToggle) { + GhostIconButton(onClick = ::onLoopToggle) { Icon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic @@ -639,14 +646,13 @@ private fun LyricsPreviewCard( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - IconButton( + GhostIconButton( onClick = onExpand, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(28.dp), ) { Icon( Iconsax.IconsaxArrowSquareUp, contentDescription = "Expand lyrics", - modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 0f019b11..ada2fc2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -22,15 +22,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -53,6 +53,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.base.VariableIconButton +import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID @@ -93,7 +97,6 @@ import kotlin.time.Duration.Companion.milliseconds fun AppLargePlayer( modifier: Modifier = Modifier, onQueue: () -> Unit = {}, - onDownload: () -> Unit = {}, onAlternativeSource: () -> Unit = {}, onMoreOptions: () -> Unit = {}, onLyrics: () -> Unit = {}, @@ -167,8 +170,8 @@ fun AppLargePlayer( Surface( modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f), - tonalElevation = 4.dp, - shadowElevation = 14.dp + tonalElevation = 0.dp, + shadowElevation = 0.dp ) { Row( modifier = Modifier @@ -209,6 +212,7 @@ fun AppLargePlayer( overflow = TextOverflow.Ellipsis ) } + Spacer(modifier = Modifier.width(10.dp)) PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, savedTracksViewModel = savedTracksViewModel, @@ -253,7 +257,10 @@ fun AppLargePlayer( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - IconButton(onClick = ::onShuffleToggle) { + VariableIconButton( + onClick = ::onShuffleToggle, + variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost + ) { Icon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -264,19 +271,22 @@ fun AppLargePlayer( } ) } - IconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } - IconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) { + GhostIconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) { Icon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", ) } - IconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } - IconButton(onClick = ::onLoopToggle) { + VariableIconButton( + onClick = ::onLoopToggle, + variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline + ) { Icon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic @@ -299,10 +309,10 @@ fun AppLargePlayer( horizontalAlignment = Alignment.End ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onQueue) { + GhostIconButton(onClick = onQueue) { Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } - IconButton(onClick = { + GhostIconButton(onClick = { val track = (currentEntry as? QueueEntry.StreamingTrack)?.track if (track != null) { downloadsViewModel.downloadTrack(track) @@ -314,18 +324,18 @@ fun AppLargePlayer( contentDescription = "Download", ) } - IconButton(onClick = onAlternativeSource) { + GhostIconButton(onClick = onAlternativeSource) { Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") } - IconButton(onClick = onLyrics) { + GhostIconButton(onClick = onLyrics) { Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") } - IconButton(onClick = onMoreOptions) { + GhostIconButton(onClick = onMoreOptions) { Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") } } Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( + GhostIconButton( onClick = { scope.launch { if (playerUiState.volume <= 0f) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt index 7268724c..c146bb8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt @@ -17,8 +17,8 @@ package dev.krtirtho.spotube.modules.shell +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.modules.saved_tracks.SavedState import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks @@ -67,7 +68,8 @@ fun PlayerHeartButton( } IconButton( onClick = ::onLike, - enabled = isSavedTrackState is SavedState.Success + enabled = isSavedTrackState is SavedState.Success, + shape = CircleShape, ) { Icon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,