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
+45 -27
View File
@@ -27,24 +27,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
}
describe("tap gesture resolution", () => {
it("defers the single-tap action until the double-tap window has elapsed", () => {
const state = createTapGestureState();
const first = tap(state, RIGHT, 1000);
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
//
// 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 -> jump and keep playing; paused ->
// jump and stay paused. The old design deferred the first tap behind a 300ms
// timer, which raced the synthesized click and produced a pause/unpause loop.
// The first tap must NOT immediately toggle play/pause — it may still
// become a double tap.
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
it("toggles play/pause immediately on the first tap", () => {
const state = createTapGestureState();
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
});
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
expect(resolved).toEqual({ action: "togglePlayPause" });
});
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const second = asSeek(tap(state, RIGHT, 1150));
@@ -52,12 +50,11 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
expect(second.seekSeconds).toBe(30);
expect(second.feedback).toBe("right");
// The deferred single-tap pause must have been cancelled.
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
// The re-toggle is what preserves the play state across a double tap.
expect(second.togglePlayPause).toBe(true);
});
it("seeks back 10s on a double tap on the left half", () => {
it("seeks back 10s AND toggles again on a second left-side tap", () => {
const state = createTapGestureState();
tap(state, LEFT, 1000);
const second = asSeek(tap(state, LEFT, 1100));
@@ -65,24 +62,44 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
expect(second.seekSeconds).toBe(-10);
expect(second.feedback).toBe("left");
expect(second.togglePlayPause).toBe(true);
});
it("treats a second tap after the window as a new pending single tap", () => {
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
const state = createTapGestureState();
let playing = true;
const apply = (outcome: ReturnType<typeof tap>) => {
if (outcome.action === "togglePlayPause") playing = !playing;
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
};
apply(tap(state, RIGHT, 1000)); // toggle -> paused
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
expect(playing).toBe(true);
// And from paused, a double tap leaves it paused.
playing = false;
apply(tap(state, RIGHT, 2000));
apply(tap(state, RIGHT, 2100));
expect(playing).toBe(false);
});
it("treats a tap after the window as a fresh first tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
expect(late.action).toBe("pending");
expect(late.action).toBe("togglePlayPause");
});
it("does not treat a third tap as another double tap", () => {
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
expect(tap(state, RIGHT, 1100).action).toBe("seek");
// Triple tap: the third tap starts a fresh pending tap rather than
// seeking again off the consumed second tap.
expect(tap(state, RIGHT, 1200).action).toBe("pending");
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
// play/pause — there is no "third tap" concept.
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
});
it("accumulates repeated double taps on the same side", () => {
@@ -105,12 +122,13 @@ describe("tap gesture resolution", () => {
expect(second.feedback).toBe("right");
});
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
state.cancel();
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
// Without cancel() this would have been the seeking second tap.
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
});
});