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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user