import { describe, it, expect } from "vitest"; import { DOUBLE_TAP_WINDOW_MS, SEEK_FORWARD_SECONDS, SEEK_BACKWARD_SECONDS, createTapGestureState, registerTap, resolveSeekTarget, clampSeekTarget, END_SEEK_MARGIN_SECONDS, isSynthesizedTouchClick, isControlSurfaceTouch, TOUCH_CLICK_SUPPRESS_MS, } from "./tapGestures"; const SCREEN_WIDTH = 1000; const LEFT = 100; const RIGHT = 900; function tap(state: ReturnType, x: number, at: number) { return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at }); } /** Narrow a tap outcome to the seek variant, failing the test if it is not one. */ function asSeek(outcome: ReturnType) { if (outcome.action !== "seek") { throw new Error(`expected a seek outcome, got "${outcome.action}"`); } return outcome; } describe("tap gesture resolution", () => { // 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. it("toggles play/pause immediately on the first tap", () => { const state = createTapGestureState(); expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" }); }); 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)); expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS); expect(second.seekSeconds).toBe(30); expect(second.feedback).toBe("right"); // The re-toggle is what preserves the play state across a double tap. expect(second.togglePlayPause).toBe(true); }); 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)); expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS); expect(second.seekSeconds).toBe(-10); expect(second.feedback).toBe("left"); expect(second.togglePlayPause).toBe(true); }); it("net play state is unchanged by a double tap (two toggles cancel out)", () => { const state = createTapGestureState(); let playing = true; const apply = (outcome: ReturnType) => { 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("togglePlayPause"); }); 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"); // 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", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); const a = asSeek(tap(state, RIGHT, 1100)); tap(state, RIGHT, 1200); const b = asSeek(tap(state, RIGHT, 1300)); expect(a.seekSeconds).toBe(30); expect(b.seekSeconds).toBe(30); }); it("uses the tap side, so a double tap split across halves follows the second tap", () => { const state = createTapGestureState(); tap(state, LEFT, 1000); const second = asSeek(tap(state, RIGHT, 1100)); expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS); expect(second.feedback).toBe("right"); }); it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); state.cancel(); // Without cancel() this would have been the seeking second tap. expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause"); }); }); describe("seek target resolution", () => { const DURATION = 600; it("adds the delta to the reported position", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130); }); it("clamps to zero when rewinding past the start", () => { expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0); }); it("clamps short of the duration when skipping past the end", () => { // Never land exactly on `duration`: hls.js would then request the segment // that starts at/after the media end, which the server never produces — // the fetch times out and the gap-controller stalls in a pause loop. expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe( DURATION - END_SEEK_MARGIN_SECONDS, ); }); it("keeps the end clamp strictly inside the media for a long transcoded item", () => { // Regression: seeking near the end of a ~105min transcoded item clamped to // the exact runtime (6330.324s), making hls.js fetch segment 1055 which // starts at 6336.33s — past the end. That segment 404s/times out forever. const runtime = 6330.324; const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime }); expect(target).toBeLessThan(runtime); expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5); }); it("does not clamp below zero for media shorter than the end margin", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0); }); it("chains off a pending target so rapid taps do not compound off a stale position", () => { // The player has not yet reported the first seek's result, so the // reported position is still the pre-seek value. const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION }); const second = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION, pendingTarget: first, }); expect(second).toBe(160); }); it("ignores a pending target once the player has caught up past it", () => { const target = resolveSeekTarget({ delta: 30, reportedPosition: 200, duration: DURATION, pendingTarget: 130, }); expect(target).toBe(230); }); it("falls back to the delta alone when duration is unknown", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130); }); }); describe("control-surface touches are not gestures", () => { // Regression: the gesture listener is on the outer container and touch events // bubble, so tapping the bottom play/pause button ran the gesture handler // (toggle #1) AND the button's own click handler (toggle #2). The two // cancelled out and the control appeared dead. it("treats a tap on a button as a control, not a gesture", () => { expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true); }); it("treats the seek bar input as a control", () => { expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true); }); it("treats anything inside the controls bar as a control", () => { expect(isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])).toBe( true, ); }); it("lets a tap on the bare video surface through as a gesture", () => { expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false); }); it("is case-insensitive about tag names", () => { expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true); }); }); describe("synthesized touch-click suppression", () => { // Regression: pausing renders a full-screen play-overlay button over the // video, so the compatibility click Android synthesizes from the tap lands on // the OVERLAY, not the