feat: replace IconButton with GhostIconButton in AppExpandedPlayer and AppLargePlayer for improved visual consistency

This commit is contained in:
Kingkor Roy Tirtho 2026-07-05 13:01:34 +06:00
parent cb27514b9b
commit 4302a27afa
5 changed files with 457 additions and 66 deletions

View File

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

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

View File

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

View File

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