Compare commits

...

2 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
c4b5ae6600
Merge 4302a27afa into 80626ba4b5 2026-07-05 07:01:42 +00:00
Kingkor Roy Tirtho
4302a27afa feat: replace IconButton with GhostIconButton in AppExpandedPlayer and AppLargePlayer for improved visual consistency 2026-07-05 13:01:34 +06:00
5 changed files with 457 additions and 66 deletions

View File

@ -238,9 +238,10 @@ fun OutlineButton(
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
hoverOnly: Boolean = false, hoverOnly: Boolean = false,
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -306,9 +307,10 @@ fun PrimaryButton(
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -365,9 +367,10 @@ fun SecondaryButton(
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 10.dp),
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -426,6 +429,7 @@ fun IconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
OutlineButton( OutlineButton(
@ -434,10 +438,12 @@ fun IconButton(
enabled = enabled, enabled = enabled,
shape = shape, shape = shape,
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
} }
@Composable @Composable
fun GhostIconButton( fun GhostIconButton(
@ -445,6 +451,7 @@ fun GhostIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
OutlineButton( OutlineButton(
@ -454,6 +461,7 @@ fun GhostIconButton(
shape = shape, shape = shape,
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
hoverOnly = true, hoverOnly = true,
colors = colors,
) { ) {
content() content()
} }
@ -465,6 +473,7 @@ fun PrimaryIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
PrimaryButton( PrimaryButton(
@ -473,6 +482,7 @@ fun PrimaryIconButton(
enabled = enabled, enabled = enabled,
shape = shape, shape = shape,
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
@ -484,6 +494,7 @@ fun SecondaryIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: androidx.compose.ui.graphics.Shape = ButtonShape, shape: androidx.compose.ui.graphics.Shape = ButtonShape,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
SecondaryButton( SecondaryButton(
@ -492,6 +503,7 @@ fun SecondaryIconButton(
enabled = enabled, enabled = enabled,
shape = shape, shape = shape,
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
colors = colors,
) { ) {
content() content()
} }
@ -501,8 +513,9 @@ fun SecondaryIconButton(
fun ButtonBadge( fun ButtonBadge(
count: Int, count: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: ButtonColors? = null,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
Box( Box(
modifier = modifier modifier = modifier
.heightIn(min = 22.dp) .heightIn(min = 22.dp)
@ -529,9 +542,10 @@ fun GroupButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 11.dp), contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 11.dp),
colors: ButtonColors? = null,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -570,9 +584,10 @@ fun GroupIconButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -604,9 +619,10 @@ fun GroupIconButton(
@Composable @Composable
fun ButtonGroup( fun ButtonGroup(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: ButtonColors? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = rememberButtonColors() val colors = colors ?: rememberButtonColors()
Surface( Surface(
modifier = modifier modifier = modifier
.defaultMinSize(minHeight = ButtonMinHeight) .defaultMinSize(minHeight = ButtonMinHeight)
@ -634,8 +650,10 @@ fun ButtonGroup(
} }
@Composable @Composable
fun ButtonGroupDivider() { fun ButtonGroupDivider(
val colors = rememberButtonColors() colors: ButtonColors? = null,
) {
val colors = colors ?: rememberButtonColors()
Box( Box(
modifier = Modifier modifier = Modifier
.width(1.dp) .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,
)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Float> = 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,
),
)
}
}

View File

@ -45,11 +45,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -68,12 +66,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue 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.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.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.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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 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.IconsaxMusicFilter
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious 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.IconsaxRepeatMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.time.Duration.Companion.milliseconds 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 // The expanded player on small screens
@ -278,15 +279,18 @@ fun AppExpandedPlayer(
snackbarHostState.showSnackbar("Already downloaded") snackbarHostState.showSnackbar("Already downloaded")
} }
} }
is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { is DownloadStatus.Failed, is DownloadStatus.Cancelled -> {
download?.let { downloadsViewModel.retry(it.id) } download?.let { downloadsViewModel.retry(it.id) }
scope.launch { scope.launch {
snackbarHostState.showSnackbar("Retrying download...") snackbarHostState.showSnackbar("Retrying download...")
} }
} }
is DownloadStatus.Downloading, is DownloadStatus.Queued -> { is DownloadStatus.Downloading, is DownloadStatus.Queued -> {
// already in progress // already in progress
} }
null -> { null -> {
val track = currentTrack val track = currentTrack
if (track != null) { if (track != null) {
@ -393,7 +397,7 @@ fun AppExpandedPlayer(
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onCollapse) { GhostIconButton(onClick = onCollapse) {
Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player")
} }
Text( Text(
@ -403,7 +407,7 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
) )
IconButton(onClick = { showMoreOptionsSheet = true }) { GhostIconButton(onClick = { showMoreOptionsSheet = true }) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options")
} }
} }
@ -462,7 +466,7 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
IconButton(onClick = onQueue) { IconButton(onClick = onQueue, shape = CircleShape) {
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
} }
} }
@ -506,7 +510,7 @@ fun AppExpandedPlayer(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = ::onShuffleToggle) { GhostIconButton(onClick = ::onShuffleToggle) {
Icon( Icon(
Iconsax.IconsaxShuffle, Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", 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") 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, 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(
Icon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = if (playerUiState.isPlaying) "Pause" else "Play",
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", modifier = Modifier.size(30.dp),
modifier = Modifier.size(30.dp), tint = MaterialTheme.colorScheme.surface,
) )
}
} }
IconButton(onClick = ::onSkipNext) { GhostIconButton(onClick = ::onSkipNext) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
IconButton(onClick = ::onLoopToggle) { GhostIconButton(onClick = ::onLoopToggle) {
Icon( Icon(
imageVector = when (playerUiState.loopState) { imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.NONE -> Iconsax.IconsaxRepeateMusic
@ -639,14 +646,13 @@ private fun LyricsPreviewCard(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
IconButton( GhostIconButton(
onClick = onExpand, onClick = onExpand,
modifier = Modifier.size(24.dp), modifier = Modifier.size(28.dp),
) { ) {
Icon( Icon(
Iconsax.IconsaxArrowSquareUp, Iconsax.IconsaxArrowSquareUp,
contentDescription = "Expand lyrics", contentDescription = "Expand lyrics",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }

View File

@ -22,15 +22,15 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.DownloadProgressIcon
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
@ -93,7 +97,6 @@ import kotlin.time.Duration.Companion.milliseconds
fun AppLargePlayer( fun AppLargePlayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onQueue: () -> Unit = {}, onQueue: () -> Unit = {},
onDownload: () -> Unit = {},
onAlternativeSource: () -> Unit = {}, onAlternativeSource: () -> Unit = {},
onMoreOptions: () -> Unit = {}, onMoreOptions: () -> Unit = {},
onLyrics: () -> Unit = {}, onLyrics: () -> Unit = {},
@ -167,8 +170,8 @@ fun AppLargePlayer(
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f), color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.78f),
tonalElevation = 4.dp, tonalElevation = 0.dp,
shadowElevation = 14.dp shadowElevation = 0.dp
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -209,6 +212,7 @@ fun AppLargePlayer(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
Spacer(modifier = Modifier.width(10.dp))
PlayerHeartButton( PlayerHeartButton(
audioPlayerQueue = audioPlayerQueue, audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel, savedTracksViewModel = savedTracksViewModel,
@ -253,7 +257,10 @@ fun AppLargePlayer(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
IconButton(onClick = ::onShuffleToggle) { VariableIconButton(
onClick = ::onShuffleToggle,
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
) {
Icon( Icon(
Iconsax.IconsaxShuffle, Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", 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") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) { GhostIconButton(onClick = ::onPlayPause, modifier = Modifier.size(44.dp)) {
Icon( Icon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
) )
} }
IconButton(onClick = ::onSkipNext) { GhostIconButton(onClick = ::onSkipNext) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
IconButton(onClick = ::onLoopToggle) { VariableIconButton(
onClick = ::onLoopToggle,
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
) {
Icon( Icon(
imageVector = when (playerUiState.loopState) { imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.NONE -> Iconsax.IconsaxRepeateMusic
@ -299,10 +309,10 @@ fun AppLargePlayer(
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onQueue) { GhostIconButton(onClick = onQueue) {
Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue")
} }
IconButton(onClick = { GhostIconButton(onClick = {
val track = (currentEntry as? QueueEntry.StreamingTrack)?.track val track = (currentEntry as? QueueEntry.StreamingTrack)?.track
if (track != null) { if (track != null) {
downloadsViewModel.downloadTrack(track) downloadsViewModel.downloadTrack(track)
@ -314,18 +324,18 @@ fun AppLargePlayer(
contentDescription = "Download", contentDescription = "Download",
) )
} }
IconButton(onClick = onAlternativeSource) { GhostIconButton(onClick = onAlternativeSource) {
Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source")
} }
IconButton(onClick = onLyrics) { GhostIconButton(onClick = onLyrics) {
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
} }
IconButton(onClick = onMoreOptions) { GhostIconButton(onClick = onMoreOptions) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton( GhostIconButton(
onClick = { onClick = {
scope.launch { scope.launch {
if (playerUiState.volume <= 0f) { if (playerUiState.volume <= 0f) {

View File

@ -17,8 +17,8 @@
package dev.krtirtho.spotube.modules.shell package dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.modules.saved_tracks.SavedState import dev.krtirtho.spotube.modules.saved_tracks.SavedState
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks
@ -67,7 +68,8 @@ fun PlayerHeartButton(
} }
IconButton( IconButton(
onClick = ::onLike, onClick = ::onLike,
enabled = isSavedTrackState is SavedState.Success enabled = isSavedTrackState is SavedState.Success,
shape = CircleShape,
) { ) {
Icon( Icon(
imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,