fix(player): tap gestures act immediately, no deferral timer (DR-098)

Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.

The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.

Replace the deferral with immediate action — there are only first and
second taps:

  1st tap: toggle play/pause
  2nd tap: seek, then toggle play/pause again

The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.

Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.

A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.

UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
This commit is contained in:
2026-07-30 14:53:20 +02:00
parent 79e10d7485
commit b565c4ae6f
11 changed files with 144 additions and 104 deletions
+52 -34
View File
@@ -1,16 +1,27 @@
/**
* 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.
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
* deferral:
*
* TRACES: UR-005, UR-061 | DR-092, DR-095 | UT-085, UT-086, UT-087, UT-088
* 1st tap: toggle play/pause
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
*
* The second toggle undoes the first, so a double tap seeks while leaving the
* play state exactly as it started — playing stays playing, paused stays paused.
*
* This replaced a design that deferred the first tap behind a 300ms timer so it
* could be cancelled if a second tap arrived. That deferral raced the
* compatibility `click` Android's WebView synthesizes after a touch tap: the
* timer cleared its own handle *before* running the toggle, reopening the guard
* that was meant to suppress the late click, which then toggled a second time.
* The result was a play/pause loop about a second apart. Acting immediately
* removes the timer, the window race, and the loop.
*
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
*/
/** A second tap within this window makes a double tap. */
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300;
/** Double tap on the right half: skip forward. */
@@ -22,9 +33,18 @@ 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 };
/** First tap: toggle play/pause right now. */
| { action: "togglePlayPause" }
/**
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
* undone and the play state survives the double tap unchanged.
*/
| {
action: "seek";
seekSeconds: number;
feedback: TapFeedback;
togglePlayPause: true;
};
export interface TapInput {
/** Tap x position, viewport pixels. */
@@ -35,32 +55,20 @@ export interface TapInput {
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).
* Forget the previous tap, so the next one is treated as a first tap. Used
* when the gesture turns out to be a swipe.
*/
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;
},
};
@@ -68,27 +76,37 @@ export function createTapGestureState(): TapGestureState {
}
/**
* 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.
* Classify a tap and return the action to perform *now*.
*
* A tap that closely follows another is the second of a pair: it seeks and
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
* first tap and simply toggles. Nothing is deferred, so there is no window to
* race and no third-tap case — a consumed pair resets the state.
*/
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
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
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" };
? {
action: "seek",
seekSeconds: SEEK_BACKWARD_SECONDS,
feedback: "left",
togglePlayPause: true,
}
: {
action: "seek",
seekSeconds: SEEK_FORWARD_SECONDS,
feedback: "right",
togglePlayPause: true,
};
}
s.lastTapTime = input.now;
s.pendingSince = input.now;
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
return { action: "togglePlayPause" };
}
/**