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,