mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
better seek bar
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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]) },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user