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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user