mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
better seek bar
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user