feat(android): MiniPlayer fill bar + slim NowPlaying scrubber + smooth playhead
android / Build + lint + test (push) Has been cancelled
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user