feat(android): MiniPlayer fill bar + slim NowPlaying scrubber + smooth playhead
android / Build + lint + test (push) Has been cancelled

Three closely-related player polish changes:

1. MiniPlayer (#74) — the bottom bar's progress is a 4dp Box-based
   fill, not a Slider. No thumb, no drag handle. Tapping the bar
   still expands to NowPlaying where scrubbing lives.

2. NowPlaying scrubber (#75) — keep the M3 Slider (still
   interactive for seek) but shrink the thumb from the 20dp default
   to 10dp via SliderDefaults.Thumb's thumbSize slot. Slider's own
   48dp hit slop is unchanged so tappability stays. Spacers above
   and below ScrubberRow drop from 8dp to 4dp.

3. Smooth playhead (#76) — new SmoothPosition.kt with
   rememberSmoothPositionMs(). PlayerController.uiState polls the
   underlying ExoPlayer position roughly every 500ms; reading it
   directly steps the scrubber by half-seconds, which reads as
   jumpy. The helper resets to the canonical positionMs on each
   tick (and on seek/track-change/duration-change) and runs a
   withFrameMillis loop while isPlaying to advance the displayed
   value at 1ms/ms between ticks. Pause/end/no-duration short-
   circuit the loop. Both MiniPlayer's fill bar and the NowPlaying
   scrubber consume the smoothed value.
This commit is contained in:
2026-06-01 19:27:17 -04:00
parent c23df8d8af
commit 8ecb2cf553
3 changed files with 106 additions and 29 deletions
@@ -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
@@ -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(),
@@ -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<Long> = 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)
}
}
}