diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 8228d5bd..b2061931 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -20,8 +20,7 @@ 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.SliderDefaults +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -148,10 +147,19 @@ fun MiniPlayer( tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp, ) { Column { - MiniSeekBar( + // Non-interactive fill bar — no scrubber thumb. Tapping + // the bar still expands to NowPlaying where scrubbing + // lives. The displayed fraction interpolates per-frame + // between PlayerController position ticks so the fill + // glides instead of stepping every 500ms. + val smoothPositionMs by rememberSmoothPositionMs( positionMs = state.positionMs, durationMs = state.durationMs, - onSeek = viewModel::seekTo, + isPlaying = state.isPlaying, + ) + MiniProgressFill( + positionMs = smoothPositionMs, + durationMs = state.durationMs, ) MiniRow( track = track, @@ -170,27 +178,25 @@ fun MiniPlayer( } @Composable -private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { +private fun MiniProgressFill(positionMs: Long, durationMs: Long) { val fraction = if (durationMs > 0) { (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) } else { 0f } - Slider( - value = fraction, - onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) }, + Box( modifier = Modifier .fillMaxWidth() - .height(SCRUBBER_ROW_HEIGHT_DP.dp), - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - // M3 default inactiveTrackColor is secondaryContainer, which the - // theme doesn't override — that's where the M3-baseline purple - // leaks in. Pin to surfaceVariant (= slate) for Flutter parity. - inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) + .height(SCRUBBER_ROW_HEIGHT_DP.dp) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(MaterialTheme.colorScheme.primary), + ) + } } @Composable diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 42bec473..96f24442 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Velocity import androidx.compose.material3.Scaffold +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Slider +import androidx.compose.ui.unit.DpSize import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.material3.SliderDefaults @@ -271,13 +273,18 @@ private fun NowPlayingBody( onToggleShuffle = viewModel::toggleShuffle, onCycleRepeat = viewModel::cycleRepeat, ) - Spacer(Modifier.height(8.dp)) - ScrubberRow( + Spacer(Modifier.height(4.dp)) + val smoothPositionMs by rememberSmoothPositionMs( positionMs = state.positionMs, durationMs = state.durationMs, + isPlaying = state.isPlaying, + ) + ScrubberRow( + positionMs = smoothPositionMs, + durationMs = state.durationMs, onSeek = viewModel::seekTo, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) TransportRow( isPlaying = state.isPlaying, onPrev = viewModel::skipToPrevious, @@ -426,6 +433,7 @@ private fun TrackHeader(title: String, artist: String, album: String) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { val accent = MaterialTheme.colorScheme.primary @@ -434,19 +442,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un } else { 0f } + val interactionSource = remember { MutableInteractionSource() } + val sliderColors = SliderDefaults.colors( + thumbColor = accent, + activeTrackColor = accent, + // M3 inactive default is secondaryContainer (purple baseline); + // pin to surfaceVariant (= slate) for Flutter parity. + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + ) Slider( value = fraction, onValueChange = { newFraction -> if (durationMs > 0) onSeek((newFraction * durationMs).toLong()) }, modifier = Modifier.fillMaxWidth(), - colors = SliderDefaults.colors( - thumbColor = accent, - activeTrackColor = accent, - // M3 inactive default is secondaryContainer (purple baseline); - // pin to surfaceVariant (= slate) for Flutter parity. - inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = sliderColors, + interactionSource = interactionSource, + // Slim 10dp thumb shrinks the scrubber's visual weight. M3 + // default is 20dp; halving keeps it tappable (Slider's own + // 48dp hit slop is unchanged) but lets it recede into the UI. + // Track left at default — the colour pin alone already + // matches Flutter's slate look. + thumb = { + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = sliderColors, + thumbSize = DpSize(10.dp, 10.dp), + ) + }, ) Row( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt new file mode 100644 index 00000000..18003011 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.runtime.withFrameMillis +import kotlinx.coroutines.isActive + +/** + * Per-frame interpolated playhead. `positionMs` comes in at the + * polling cadence of [com.fabledsword.minstrel.player.PlayerController] + * (~500ms today) — using it directly for the scrubber paints a sudden + * 500ms jump on each tick. This wraps it in a [produceState] that + * resets to the canonical value on every emission and then advances + * forward at 1ms/ms while the player is playing, so the displayed + * position moves continuously at the natural playback rate. + * + * On track end / pause / seek the produceState's keys change and the + * coroutine restarts from the new canonical value — so when the + * canonical position changes by more than a frame can account for + * (skip, scrub, seek), we snap instead of slewing across the bar. + * + * For paused state the position stays at the canonical value with no + * extrapolation; for ended/durationMs<=0 the helper returns + * `positionMs` unchanged. + */ +@Composable +fun rememberSmoothPositionMs( + positionMs: Long, + durationMs: Long, + isPlaying: Boolean, +): State = produceState( + initialValue = positionMs, + key1 = positionMs, + key2 = isPlaying, + key3 = durationMs, +) { + value = positionMs + if (!isPlaying || durationMs <= 0L) return@produceState + val startFrame = withFrameMillis { it } + val startPos = positionMs + while (isActive) { + withFrameMillis { now -> + val advanced = startPos + (now - startFrame) + value = advanced.coerceAtMost(durationMs) + } + } +}