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.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)
}
}
}