feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be the first half of a double tap. Play/pause is therefore deferred until the 300ms double-tap window closes, and cancelled outright if a second tap arrives, so a double tap seeks without also toggling pause. Forward skip moves from 10s to 30s (back stays 10s), for both double tap and the keyboard arrows. The timing rules live in tapGestures.ts so they are unit-testable without mounting the player. Rapid double taps now chain off a still-in-flight seek target instead of all resolving against the same not-yet-updated position. TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tap-gesture interpretation for the video player surface.
|
||||
*
|
||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
||||
* until the double-tap window closes, and cancelled outright if a second tap
|
||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
||||
*
|
||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
||||
*/
|
||||
|
||||
/** A second tap within this window makes a double tap. */
|
||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
||||
|
||||
/** Double tap on the right half: skip forward. */
|
||||
export const SEEK_FORWARD_SECONDS = 30;
|
||||
|
||||
/** Double tap on the left half: skip back. */
|
||||
export const SEEK_BACKWARD_SECONDS = -10;
|
||||
|
||||
export type TapFeedback = "left" | "right";
|
||||
|
||||
export type TapOutcome =
|
||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
||||
| { action: "pending"; pendingAfterMs: number }
|
||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
||||
|
||||
export interface TapInput {
|
||||
/** Tap x position, viewport pixels. */
|
||||
x: number;
|
||||
screenWidth: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface TapGestureState {
|
||||
/**
|
||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
||||
* became a double tap, or was cancelled).
|
||||
*/
|
||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
interface InternalState extends TapGestureState {
|
||||
lastTapTime: number;
|
||||
pendingSince: number | null;
|
||||
}
|
||||
|
||||
export function createTapGestureState(): TapGestureState {
|
||||
const state: InternalState = {
|
||||
lastTapTime: 0,
|
||||
pendingSince: null,
|
||||
resolvePending(now: number) {
|
||||
if (state.pendingSince === null) return null;
|
||||
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
||||
state.pendingSince = null;
|
||||
return { action: "togglePlayPause" };
|
||||
},
|
||||
cancel() {
|
||||
state.pendingSince = null;
|
||||
state.lastTapTime = 0;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
||||
* the window returns the seek and clears the pending play/pause.
|
||||
*/
|
||||
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||
const s = state as InternalState;
|
||||
const sinceLastTap = input.now - s.lastTapTime;
|
||||
|
||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||
// Second tap: cancel the deferred play/pause and seek instead.
|
||||
s.pendingSince = null;
|
||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
||||
const isLeftSide = input.x < input.screenWidth / 2;
|
||||
return isLeftSide
|
||||
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
||||
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
||||
}
|
||||
|
||||
s.lastTapTime = input.now;
|
||||
s.pendingSince = input.now;
|
||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
||||
}
|
||||
|
||||
export interface SeekTargetInput {
|
||||
/** Relative offset in seconds (negative rewinds). */
|
||||
delta: number;
|
||||
/** Latest position reported by the player — the authoritative source. */
|
||||
reportedPosition: number;
|
||||
/** Media duration; 0/unknown disables the upper clamp. */
|
||||
duration: number;
|
||||
/**
|
||||
* Target of a seek already requested but not yet reflected in
|
||||
* `reportedPosition`. Consecutive double taps chain off this so they add up
|
||||
* instead of all resolving against the same stale position.
|
||||
*/
|
||||
pendingTarget?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative skip to the absolute position the facade expects.
|
||||
*
|
||||
* The player facade seeks by absolute position only (the backend picks the seek
|
||||
* strategy), so the delta is applied here — against the pending target when one
|
||||
* is still in flight and still ahead of what the player has reported.
|
||||
*/
|
||||
export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
const { delta, reportedPosition, duration, pendingTarget } = input;
|
||||
|
||||
const base =
|
||||
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
|
||||
? pendingTarget
|
||||
: reportedPosition;
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
return target;
|
||||
}
|
||||
Reference in New Issue
Block a user