Android v1 polish + Web UI flavor pass #65

Merged
bvandeusen merged 13 commits from dev into main 2026-06-01 20:17:49 -04:00
3 changed files with 106 additions and 29 deletions
Showing only changes of commit 8ecb2cf553 - Show all commits
@@ -20,8 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -148,10 +147,19 @@ fun MiniPlayer(
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp, tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
) { ) {
Column { 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, positionMs = state.positionMs,
durationMs = state.durationMs, durationMs = state.durationMs,
onSeek = viewModel::seekTo, isPlaying = state.isPlaying,
)
MiniProgressFill(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
) )
MiniRow( MiniRow(
track = track, track = track,
@@ -170,27 +178,25 @@ fun MiniPlayer(
} }
@Composable @Composable
private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
val fraction = if (durationMs > 0) { val fraction = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else { } else {
0f 0f
} }
Slider( Box(
value = fraction,
onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(SCRUBBER_ROW_HEIGHT_DP.dp), .height(SCRUBBER_ROW_HEIGHT_DP.dp)
colors = SliderDefaults.colors( .background(MaterialTheme.colorScheme.surfaceVariant),
thumbColor = MaterialTheme.colorScheme.primary, ) {
activeTrackColor = MaterialTheme.colorScheme.primary, Box(
// M3 default inactiveTrackColor is secondaryContainer, which the modifier = Modifier
// theme doesn't override — that's where the M3-baseline purple .fillMaxHeight()
// leaks in. Pin to surfaceVariant (= slate) for Flutter parity. .fillMaxWidth(fraction)
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, .background(MaterialTheme.colorScheme.primary),
), )
) }
} }
@Composable @Composable
@@ -28,10 +28,12 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.ui.unit.DpSize
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
@@ -271,13 +273,18 @@ private fun NowPlayingBody(
onToggleShuffle = viewModel::toggleShuffle, onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat, onCycleRepeat = viewModel::cycleRepeat,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
ScrubberRow( val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs, positionMs = state.positionMs,
durationMs = state.durationMs, durationMs = state.durationMs,
isPlaying = state.isPlaying,
)
ScrubberRow(
positionMs = smoothPositionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo, onSeek = viewModel::seekTo,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
TransportRow( TransportRow(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
onPrev = viewModel::skipToPrevious, onPrev = viewModel::skipToPrevious,
@@ -426,6 +433,7 @@ private fun TrackHeader(title: String, artist: String, album: String) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
val accent = MaterialTheme.colorScheme.primary val accent = MaterialTheme.colorScheme.primary
@@ -434,19 +442,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
} else { } else {
0f 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( Slider(
value = fraction, value = fraction,
onValueChange = { newFraction -> onValueChange = { newFraction ->
if (durationMs > 0) onSeek((newFraction * durationMs).toLong()) if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors( colors = sliderColors,
thumbColor = accent, interactionSource = interactionSource,
activeTrackColor = accent, // Slim 10dp thumb shrinks the scrubber's visual weight. M3
// M3 inactive default is secondaryContainer (purple baseline); // default is 20dp; halving keeps it tappable (Slider's own
// pin to surfaceVariant (= slate) for Flutter parity. // 48dp hit slop is unchanged) but lets it recede into the UI.
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, // 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( Row(
modifier = Modifier.fillMaxWidth(), 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)
}
}
}