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
+33 -31
View File
@@ -112,7 +112,10 @@
let touchStartY = $state(0);
let touchStartTime = $state(0);
let tapGestures = createTapGestureState();
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
// When a touch tap last ran the gesture handler, so the compatibility click
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
let lastTouchTapAt = 0;
const TOUCH_CLICK_SUPPRESS_MS = 700;
let brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -719,11 +722,6 @@
if (debugLogInterval) {
clearInterval(debugLogInterval);
}
// A deferred single tap must not fire play/pause after teardown.
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
tapGestures.cancel();
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
@@ -1457,25 +1455,22 @@
now: Date.now(),
});
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
// Suppress the compatibility click this touch will synthesize.
lastTouchTapAt = Date.now();
if (outcome.action === "seek") {
e.preventDefault();
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
// leaves the play state as it was (playing keeps playing, paused stays
// paused).
if (outcome.togglePlayPause) togglePlayPause();
return;
}
// Single tap so far: defer play/pause until the double-tap window closes,
// so a double tap seeks without also toggling pause.
tapTimeout = setTimeout(() => {
tapTimeout = null;
if (tapGestures.resolvePending(Date.now())) {
togglePlayPause();
}
}, outcome.pendingAfterMs);
// First tap: act now. Nothing is deferred, so there is no timer to race the
// compatibility click Android synthesizes after a touch tap (see DR-098).
togglePlayPause();
}
function handleTouchMove(e: TouchEvent) {
@@ -1488,14 +1483,16 @@
// Minimum movement to register as swipe (50px)
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
swipeGestureActive = true;
// This is a swipe, not a tap — drop the deferred play/pause.
tapGestures.cancel();
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
// Only on the frame the gesture is first recognised as a swipe — this runs
// on every touchmove, and the correction below must happen exactly once.
if (!swipeGestureActive) {
// The touchstart already toggled play/pause (taps act immediately now),
// so undo it: a swipe must not change the play state. Forget the tap too,
// so it cannot pair with a later tap into a spurious seek.
togglePlayPause();
tapGestures.cancel();
}
swipeGestureActive = true;
// Brightness control on vertical swipe
swipeType = "brightness";
@@ -1514,14 +1511,19 @@
}
/**
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
* by `handleTouchStart` (which defers play/pause past the double-tap window),
* so the compatibility click that follows a tap must be ignored here —
* otherwise it pauses on the first tap of a double tap.
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* `handleTouchStart`, so the compatibility click the browser synthesizes after
* a tap must be ignored here or every tap toggles twice.
*
* Two independent guards, because neither alone is sufficient: `detail === 0`
* catches the synthesized click on engines that report it, and the recency
* check covers engines that report a real `detail` — Android's WebView can
* deliver the click well after the touch, which is what defeated the previous
* timer-based guard (see DR-098).
*/
function handleVideoClick(e: MouseEvent) {
// A click synthesized from a touch reports no pointer movement detail.
if (e.detail === 0 || tapTimeout !== null) return;
if (e.detail === 0) return;
if (Date.now() - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS) return;
togglePlayPause();
}