Android v1 polish + Web UI flavor pass #65
@@ -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