feat(android): scrubber thumb pill — fixes off-center perception
android / Build + lint + test (push) Successful in 4m0s

User report: the round dot didn't read as vertically centered on
the 4dp track even though geometrically it was (M3's SliderLayout
centers the track slot within the thumb's height). A small circle
on a thin horizontal bar is a known perceptual offset — the eye
expects the bar to bisect the circle, but the circle's mass extends
above and below in equal amounts the brain reads as a lift.

Swap the 14dp circle for a 4dp x 18dp vertical pill (CircleShape
on a non-square Box renders as a stadium). Same width as the track,
clearly taller — the bar visibly passes through the pill's
horizontal axis with no ambiguity. Also aligns with M3 expressive's
new vertical-handle slider direction.

Updates the ScrubTrack docstring that still referenced the prior
14dp-on-4dp pairing.
This commit is contained in:
2026-06-02 22:48:40 -04:00
parent 94b3b87785
commit ad7e57fe66
@@ -88,6 +88,8 @@ private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
private val SCRUB_THUMB_WIDTH_DP = 4.dp
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
// Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit;
@@ -478,15 +480,22 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
modifier = Modifier.fillMaxWidth(),
colors = sliderColors,
interactionSource = interactionSource,
// Plain filled circle to match the web client's `<input
// type="range" accent-color>` thumb — flat, no state-layer
// ring, no border. M3's SliderDefaults.Thumb paints a state
// layer halo on press; we drop it for visual parity. Slider's
// 48dp hit slop still applies, so tapability is unchanged.
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
// A small circle on a thin horizontal bar reads as visually
// off-center even when geometrically aligned — the eye
// expects the bar to bisect the thumb but a 14dp circle's
// mass extends above and below in equal amounts that the
// brain perceives as offset. A vertical pill the same width
// as the track removes the ambiguity: the bar passes through
// the pill's horizontal axis cleanly. Also matches M3's
// expressive-slider handle direction. State-layer halo is
// still dropped for visual parity with the web scrubber.
// Slider's 48dp hit slop still applies, so tapability is
// unchanged.
thumb = {
Box(
modifier = Modifier
.size(14.dp)
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
.clip(CircleShape)
.background(accent),
)
@@ -513,12 +522,11 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
/**
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
* and reads as a heavy pill rather than a measurement line; making
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
* restores the "handle on a string" cue your eye reads as "tool,
* draggable." Also drops M3's stop indicator dot, which the web
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
* detekt's LongMethod ceiling.
* and reads as a heavy pill rather than a measurement line; the
* pill-thumb-on-thin-track pairing restores the "handle on a string"
* cue your eye reads as "tool, draggable." Also drops M3's stop
* indicator dot, which the web scrubber doesn't have. Extracted so
* [ScrubberRow] stays under detekt's LongMethod ceiling.
*/
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {