/** * 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; }