From 8ecb2cf553bc0e570edcb067d211f608f3a0eaf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:27:17 -0400 Subject: [PATCH] feat(android): MiniPlayer fill bar + slim NowPlaying scrubber + smooth playhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../minstrel/player/ui/MiniPlayer.kt | 42 +++++++++------- .../minstrel/player/ui/NowPlayingScreen.kt | 45 ++++++++++++----- .../minstrel/player/ui/SmoothPosition.kt | 48 +++++++++++++++++++ 3 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt 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) + } + } +}