better seek bar

This commit is contained in:
Boof2015
2026-07-15 16:07:51 -04:00
parent 7a4e1cc283
commit b750e10cb7
11 changed files with 413 additions and 24 deletions
+43 -5
View File
@@ -13,6 +13,7 @@ import {
Canvas,
Group,
Path,
Rect,
Skia,
rect
} from '@shopify/react-native-skia';
@@ -24,11 +25,17 @@ import { downsampleWaveform, getWaveform } from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { usePlayerStore } from '@/stores/playerStore';
import { playHaptic } from '@/lib/haptics';
import {
beginScrubDetents,
updateScrubDetents,
type ScrubDetentState,
} from './waveformScrubDetents';
const CANVAS_HEIGHT = 58;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver
const PLAYHEAD_WIDTH = 2;
type WaveformQuality = 'preview' | 'accurate';
interface WaveformSeekBarProps {
@@ -81,6 +88,7 @@ export function WaveformSeekBar({
const widthRef = useRef(0);
const scrubRef = useRef<number | null>(null);
const grantRef = useRef({ fraction: 0, pageX: 0 });
const detentRef = useRef<ScrubDetentState | null>(null);
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
// Load (cache-first) the offline peaks whenever the track changes.
@@ -124,27 +132,44 @@ export function WaveformSeekBar({
const handleGrant = (event: GestureResponderEvent) => {
const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current));
grantRef.current = { fraction, pageX: event.nativeEvent.pageX };
detentRef.current = beginScrubDetents(fraction * widthRef.current, widthRef.current);
setScrub(fraction);
playHaptic('threshold');
};
const handleMove = (event: GestureResponderEvent) => {
const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current);
setScrub(clamp(grantRef.current.fraction + delta));
const fraction = clamp(grantRef.current.fraction + delta);
setScrub(fraction);
const detents = detentRef.current;
if (!detents) return;
const update = updateScrubDetents(
detents,
fraction * widthRef.current,
widthRef.current,
Date.now()
);
detentRef.current = update.state;
if (update.shouldTick) playHaptic('scrubStep');
};
const handleRelease = () => {
const fraction = scrubRef.current ?? grantRef.current.fraction;
const target = fraction * duration;
detentRef.current = null;
onSeek(target);
setScrub(null);
};
const handleTerminate = () => {
detentRef.current = null;
setScrub(null);
};
// Displayed position: scrub > pending seek target > live progress. The player
// store clears pendingSeek only after native progress acknowledges the target
// or the guard times out, so stale RNTP progress cannot bounce the UI back.
const liveTime = isPlaying ? smoothTime : currentTime;
const liveFraction = duration > 0 ? Math.min(1, liveTime / duration) : 0;
const liveFraction = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
const shownTime = fraction * duration;
@@ -171,6 +196,10 @@ export function WaveformSeekBar({
}, [source, barCount, barWidth, height]);
const splitX = fraction * barWidth;
const playheadX = Math.min(
Math.max(0, barWidth - PLAYHEAD_WIDTH),
Math.max(0, splitX - PLAYHEAD_WIDTH / 2)
);
return (
<View>
@@ -183,7 +212,7 @@ export function WaveformSeekBar({
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={() => setScrub(null)}
onResponderTerminate={handleTerminate}
accessibilityRole="adjustable"
accessibilityLabel="Seek"
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
@@ -195,6 +224,15 @@ export function WaveformSeekBar({
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), height)}>
<Path path={barsPath} color={colors.glassBorder} />
</Group>
{barWidth > 0 ? (
<Rect
x={playheadX}
y={0}
width={PLAYHEAD_WIDTH}
height={height}
color={colors.textPrimary}
/>
) : null}
</Canvas>
</View>
<View style={styles.times}>
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
SCRUB_DETENT_SPACING_DP,
SCRUB_TICK_ACTIVATION_DISTANCE_DP,
SCRUB_TICK_MIN_INTERVAL_MS,
beginScrubDetents,
updateScrubDetents,
} from './waveformScrubDetents.ts';
test('begins silently and stays silent within one detent or while stationary', () => {
const initial = beginScrubDetents(4, 120);
assert.deepEqual(initial, {
detentIndex: 0,
lastTickAtMs: null,
startPositionDp: 4,
activated: false,
});
const movedInside = updateScrubDetents(initial, 11, 120, 1_000);
assert.equal(movedInside.shouldTick, false);
const held = updateScrubDetents(movedInside.state, 11, 120, 10_000);
assert.equal(held.shouldTick, false);
});
test('keeps tap jitter silent even when it crosses a detent boundary', () => {
const initial = beginScrubDetents(11, 120);
const jitter = updateScrubDetents(
initial,
11 + SCRUB_TICK_ACTIVATION_DISTANCE_DP - 1,
120,
1_000
);
assert.equal(jitter.state.detentIndex, 1);
assert.equal(jitter.state.activated, false);
assert.equal(jitter.shouldTick, false);
const held = updateScrubDetents(jitter.state, 16, 120, 10_000);
assert.equal(held.shouldTick, false);
});
test('emits one step whenever slow movement crosses a spatial detent', () => {
const initial = beginScrubDetents(0, 120);
const first = updateScrubDetents(initial, SCRUB_DETENT_SPACING_DP, 120, 1_000);
assert.equal(first.shouldTick, true);
const second = updateScrubDetents(
first.state,
SCRUB_DETENT_SPACING_DP * 2,
120,
1_000 + SCRUB_TICK_MIN_INTERVAL_MS
);
assert.equal(second.shouldTick, true);
});
test('rate-limits fast crossings without queuing a catch-up tick', () => {
const initial = beginScrubDetents(0, 120);
const first = updateScrubDetents(initial, SCRUB_DETENT_SPACING_DP, 120, 1_000);
const skipped = updateScrubDetents(
first.state,
SCRUB_DETENT_SPACING_DP * 3,
120,
1_020
);
assert.equal(skipped.shouldTick, false);
assert.equal(skipped.state.detentIndex, 3);
const heldAfterLimit = updateScrubDetents(
skipped.state,
SCRUB_DETENT_SPACING_DP * 3,
120,
2_000
);
assert.equal(heldAfterLimit.shouldTick, false);
const nextMovement = updateScrubDetents(
heldAfterLimit.state,
SCRUB_DETENT_SPACING_DP * 4,
120,
2_001
);
assert.equal(nextMovement.shouldTick, true);
});
test('direction reversals tick only when they cross a different detent', () => {
const initial = beginScrubDetents(30, 120);
const forward = updateScrubDetents(initial, 48, 120, 1_000);
assert.equal(forward.shouldTick, true);
const sameDetent = updateScrubDetents(forward.state, 49, 120, 1_100);
assert.equal(sameDetent.shouldTick, false);
const reverse = updateScrubDetents(sameDetent.state, 35, 120, 1_100);
assert.equal(reverse.shouldTick, true);
});
test('clamps out-of-bounds and invalid positions to the waveform', () => {
const beforeStart = beginScrubDetents(-100, 120);
assert.equal(beforeStart.detentIndex, 0);
const pastEnd = updateScrubDetents(beforeStart, 999, 120, 1_000);
assert.equal(pastEnd.state.detentIndex, Math.floor(120 / SCRUB_DETENT_SPACING_DP));
assert.equal(pastEnd.shouldTick, true);
const invalid = beginScrubDetents(Number.NaN, 0);
assert.deepEqual(invalid, {
detentIndex: 0,
lastTickAtMs: null,
startPositionDp: 0,
activated: false,
});
});
+72
View File
@@ -0,0 +1,72 @@
export const SCRUB_DETENT_SPACING_DP = 16;
export const SCRUB_TICK_MIN_INTERVAL_MS = 90;
export const SCRUB_TICK_ACTIVATION_DISTANCE_DP = 6;
export interface ScrubDetentState {
detentIndex: number;
lastTickAtMs: number | null;
startPositionDp: number;
activated: boolean;
}
export interface ScrubDetentUpdate {
state: ScrubDetentState;
shouldTick: boolean;
}
function clampPosition(positionDp: number, widthDp: number): number {
if (!Number.isFinite(positionDp) || !Number.isFinite(widthDp) || widthDp <= 0) return 0;
return Math.min(widthDp, Math.max(0, positionDp));
}
function detentIndex(positionDp: number, widthDp: number): number {
return Math.floor(clampPosition(positionDp, widthDp) / SCRUB_DETENT_SPACING_DP);
}
/** Begin silently at the detent under the initial touch. */
export function beginScrubDetents(positionDp: number, widthDp: number): ScrubDetentState {
const startPositionDp = clampPosition(positionDp, widthDp);
return {
detentIndex: detentIndex(startPositionDp, widthDp),
lastTickAtMs: null,
startPositionDp,
activated: false,
};
}
/**
* Advance to the detent under the finger. The index always advances even when
* rate-limited, so skipped ticks never catch up after movement stops.
*/
export function updateScrubDetents(
state: ScrubDetentState,
positionDp: number,
widthDp: number,
nowMs: number
): ScrubDetentUpdate {
const nextPositionDp = clampPosition(positionDp, widthDp);
const nextIndex = detentIndex(nextPositionDp, widthDp);
const activated =
state.activated ||
Math.abs(nextPositionDp - state.startPositionDp) >= SCRUB_TICK_ACTIVATION_DISTANCE_DP;
if (!activated || nextIndex === state.detentIndex) {
return {
state: { ...state, detentIndex: nextIndex, activated },
shouldTick: false,
};
}
const canTick =
state.lastTickAtMs == null ||
nowMs - state.lastTickAtMs >= SCRUB_TICK_MIN_INTERVAL_MS;
return {
state: {
detentIndex: nextIndex,
lastTickAtMs: canTick ? nowMs : state.lastTickAtMs,
startPositionDp: state.startPositionDp,
activated,
},
shouldTick: canTick,
};
}