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
+1
View File
@@ -76,6 +76,7 @@
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/components/waveformScrubDetents.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts",
+1
View File
@@ -31,6 +31,7 @@ const SEMANTIC_EVENTS: {
{ event: 'toggleOff', label: 'Toggle off', description: 'A setting leaves its active state.' },
{ event: 'selection', label: 'Selection', description: 'A discrete choice changes.' },
{ event: 'frequentStep', label: 'Frequent step', description: 'A repeated row or letter crossing.' },
{ event: 'scrubStep', label: 'Scrub step', description: 'A fine seek detent passes under the finger.' },
{ event: 'threshold', label: 'Threshold', description: 'A gesture becomes armed.' },
{ event: 'action', label: 'Action', description: 'A direct control commits.' },
{ event: 'dragStart', label: 'Drag start', description: 'An item is picked up.' },
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyPlaybackSnapshot,
createPlaybackClock,
projectPlaybackClock,
setPlaybackClockRunning,
} from './playbackClock.ts';
test('projects elapsed time only while playback is running', () => {
const clock = createPlaybackClock(10, 120, true, 1_000);
assert.equal(projectPlaybackClock(clock, 120, 2_500), 11.5);
});
test('freezes on pause and excludes a long paused interval on resume', () => {
const playing = createPlaybackClock(10, 120, true, 1_000);
const paused = setPlaybackClockRunning(playing, false, 120, 2_500);
assert.equal(projectPlaybackClock(paused, 120, 62_500), 11.5);
const resumed = setPlaybackClockRunning(paused, true, 120, 62_500);
assert.equal(projectPlaybackClock(resumed, 120, 63_500), 12.5);
});
test('accepts authoritative progress snapshots while playing or paused', () => {
const playing = createPlaybackClock(10, 120, true, 1_000);
const sought = applyPlaybackSnapshot(playing, 75, 120, 2_000);
assert.equal(projectPlaybackClock(sought, 120, 2_500), 75.5);
const paused = setPlaybackClockRunning(sought, false, 120, 2_500);
const pausedSeek = applyPlaybackSnapshot(paused, 25, 120, 50_000);
assert.equal(projectPlaybackClock(pausedSeek, 120, 90_000), 25);
});
test('clamps projections and duration changes to valid track bounds', () => {
const nearEnd = createPlaybackClock(119, 120, true, 1_000);
assert.equal(projectPlaybackClock(nearEnd, 120, 10_000), 120);
assert.equal(projectPlaybackClock(nearEnd, 60, 10_000), 60);
const invalid = applyPlaybackSnapshot(nearEnd, Number.NaN, 120, 10_000);
assert.equal(projectPlaybackClock(invalid, 120, 10_000), 0);
});
test('a track reset replaces the previous interpolation anchor', () => {
const previousTrack = createPlaybackClock(80, 240, true, 1_000);
const nextTrack = applyPlaybackSnapshot(previousTrack, 0, 180, 5_000);
assert.equal(projectPlaybackClock(nextTrack, 180, 5_500), 0.5);
});
+65
View File
@@ -0,0 +1,65 @@
export interface PlaybackClock {
anchorTime: number;
anchorTimestampMs: number;
isPlaying: boolean;
}
export function clampPlaybackTime(value: number, duration: number): number {
if (!Number.isFinite(value)) return 0;
if (duration <= 0) return Math.max(0, value);
return Math.min(duration, Math.max(0, value));
}
export function createPlaybackClock(
currentTime: number,
duration: number,
isPlaying: boolean,
nowMs = 0
): PlaybackClock {
return {
anchorTime: clampPlaybackTime(currentTime, duration),
anchorTimestampMs: nowMs,
isPlaying,
};
}
export function projectPlaybackClock(
clock: PlaybackClock,
duration: number,
nowMs: number
): number {
if (!clock.isPlaying) return clampPlaybackTime(clock.anchorTime, duration);
const elapsedSeconds = Math.max(0, nowMs - clock.anchorTimestampMs) / 1000;
return clampPlaybackTime(clock.anchorTime + elapsedSeconds, duration);
}
/** Re-anchor to an authoritative RNTP/store progress snapshot. */
export function applyPlaybackSnapshot(
clock: PlaybackClock,
currentTime: number,
duration: number,
nowMs: number
): PlaybackClock {
return {
anchorTime: clampPlaybackTime(currentTime, duration),
anchorTimestampMs: nowMs,
isPlaying: clock.isPlaying,
};
}
/**
* Freeze the projected clock when playback stops, or resume from that frozen
* point without counting any time spent paused.
*/
export function setPlaybackClockRunning(
clock: PlaybackClock,
isPlaying: boolean,
duration: number,
nowMs: number
): PlaybackClock {
return {
anchorTime: projectPlaybackClock(clock, duration, nowMs),
anchorTimestampMs: nowMs,
isPlaying,
};
}
+36 -17
View File
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import {
applyPlaybackSnapshot,
clampPlaybackTime,
createPlaybackClock,
projectPlaybackClock,
setPlaybackClockRunning,
} from './playbackClock';
const DISPLAY_FRAME_MS = 66;
function clampTime(value: number, duration: number): number {
if (!Number.isFinite(value)) return 0;
if (duration <= 0) return Math.max(0, value);
return Math.min(duration, Math.max(0, value));
}
/**
* Interpolates displayed playback time between RNTP progress snapshots. RNTP
* remains authoritative; this only makes the visible timeline move smoothly
@@ -18,21 +19,41 @@ export function useSmoothPlaybackTime(
duration: number,
isPlaying: boolean
): number {
const [displayTime, setDisplayTime] = useState(() => clampTime(currentTime, duration));
const anchorRef = useRef({
time: clampTime(currentTime, duration),
timestamp: 0,
});
const [displayTime, setDisplayTime] = useState(() =>
clampPlaybackTime(currentTime, duration)
);
const clockRef = useRef(createPlaybackClock(currentTime, duration, isPlaying));
useEffect(() => {
const next = clampTime(currentTime, duration);
anchorRef.current = { time: next, timestamp: Date.now() };
const now = Date.now();
const nextClock = applyPlaybackSnapshot(
clockRef.current,
currentTime,
duration,
now
);
clockRef.current = nextClock;
const next = projectPlaybackClock(nextClock, duration, now);
const raf = requestAnimationFrame(() => setDisplayTime(next));
return () => cancelAnimationFrame(raf);
}, [currentTime, duration]);
useEffect(() => {
if (!isPlaying || duration <= 0) return;
const now = Date.now();
const nextClock = setPlaybackClockRunning(
clockRef.current,
isPlaying,
duration,
now
);
clockRef.current = nextClock;
if (!isPlaying || duration <= 0) {
const frozen = projectPlaybackClock(nextClock, duration, now);
const freezeRaf = requestAnimationFrame(() => setDisplayTime(frozen));
return () => cancelAnimationFrame(freezeRaf);
}
let raf = 0;
let lastPaint = 0;
@@ -40,9 +61,7 @@ export function useSmoothPlaybackTime(
raf = requestAnimationFrame(tick);
if (frameTime - lastPaint < DISPLAY_FRAME_MS) return;
lastPaint = frameTime;
const anchor = anchorRef.current;
const elapsed = (Date.now() - anchor.timestamp) / 1000;
setDisplayTime(clampTime(anchor.time + elapsed, duration));
setDisplayTime(projectPlaybackClock(clockRef.current, duration, Date.now()));
};
raf = requestAnimationFrame(tick);
+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,
};
}
+6
View File
@@ -3,6 +3,7 @@ export type HapticEvent =
| 'toggleOff'
| 'selection'
| 'frequentStep'
| 'scrubStep'
| 'threshold'
| 'thresholdExit'
| 'action'
@@ -52,6 +53,11 @@ export const HAPTIC_DEFINITIONS: Readonly<Record<HapticEvent, HapticDefinition>>
toggleOff: { semantic: 'toggle-off', fallback: 'selection', recipeId: 'toggleOffB' },
selection: { semantic: 'segment-tick', fallback: 'selection' },
frequentStep: { semantic: 'segment-frequent-tick', fallback: 'selection' },
scrubStep: {
semantic: 'segment-frequent-tick',
fallback: 'selection',
recipeId: 'scrubStepA',
},
threshold: { semantic: 'gesture-start', fallback: 'lightImpact' },
thresholdExit: {
semantic: 'gesture-end',
+10
View File
@@ -210,6 +210,16 @@ export const HAPTIC_RECIPE_SECTIONS: readonly HapticRecipeSection[] = [
{ id: 'dragPlacementB', label: 'B · soft landing', steps: recipe(['quickFall', 0.55], ['lowTick', 0.6, HAPTIC_GAPS_MS.neutral]) },
],
},
{
id: 'scrubStep',
label: 'Scrub detent',
description: 'A spatial detent passes beneath the seek finger.',
leadingCandidateId: 'scrubStepA',
selectionStatus: 'selected',
candidates: [
{ id: 'scrubStepA', label: 'A · crisp linear click', steps: recipe(['click', 0.65]) },
],
},
],
},
{
+19 -2
View File
@@ -24,6 +24,7 @@ const expectedSemantics: Record<HapticEvent, string> = {
toggleOff: 'toggle-off',
selection: 'segment-tick',
frequentStep: 'segment-frequent-tick',
scrubStep: 'segment-frequent-tick',
threshold: 'gesture-start',
thresholdExit: 'gesture-end',
action: 'virtual-key',
@@ -44,6 +45,7 @@ const expectedFallbacks: Record<HapticEvent, string> = {
toggleOff: 'selection',
selection: 'selection',
frequentStep: 'selection',
scrubStep: 'selection',
threshold: 'lightImpact',
thresholdExit: 'lightImpact',
action: 'lightImpact',
@@ -114,13 +116,14 @@ test('maps every application event to an Android semantic haptic', () => {
test('keeps all tuning candidates within the native recipe contract', () => {
assert.equal(HAPTIC_RECIPE_SECTIONS.length, 6);
assert.equal(HAPTIC_RECIPE_GROUPS.length, 16);
assert.equal(HAPTIC_RECIPE_GROUPS.length, 17);
assert.deepEqual(
HAPTIC_RECIPE_SECTIONS.flatMap((section) => section.groups),
HAPTIC_RECIPE_GROUPS
);
for (const group of HAPTIC_RECIPE_GROUPS) {
assert.equal(group.candidates.length, group.id.startsWith('timing') ? 4 : 2);
const candidateCount = group.id.startsWith('timing') ? 4 : group.id === 'scrubStep' ? 1 : 2;
assert.equal(group.candidates.length, candidateCount);
for (const candidate of group.candidates) {
assert.equal(validateHapticRecipe(candidate.steps), true, candidate.id);
}
@@ -159,6 +162,7 @@ test('records the retimed vote and keeps every composition articulated', () => {
toggleOff: 'toggleOffB',
dragPickup: 'dragPickupB',
dragPlacement: 'dragPlacementA',
scrubStep: 'scrubStepA',
confirm: 'confirmA',
reject: 'rejectB',
thresholdExit: 'thresholdExitA',
@@ -212,6 +216,19 @@ test('offers two articulated reject rhythms for a tactile no', () => {
);
});
test('keeps seek detents crisp, strong, and capability-gated', () => {
const scrubStep = hapticRecipeCandidate('scrubStepA');
assert.ok(scrubStep);
assert.deepEqual(scrubStep.steps, [
{ primitive: 'click', scale: 0.65, delayMs: 0 },
]);
assert.equal(canPlayHapticRecipe(scrubStep.steps, capabilities()), true);
assert.equal(canPlayHapticRecipe(scrubStep.steps, capabilities(['tick'])), false);
assert.deepEqual(unsupportedRecipePrimitives(scrubStep.steps, capabilities(['tick'])), [
'click',
]);
});
test('rejects invalid scale, delay, and empty recipes', () => {
assert.equal(validateHapticRecipe([]), false);
assert.equal(validateHapticRecipe([{ primitive: 'click', scale: 0 }]), false);