feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be the first half of a double tap. Play/pause is therefore deferred until the 300ms double-tap window closes, and cancelled outright if a second tap arrives, so a double tap seeks without also toggling pause. Forward skip moves from 10s to 30s (back stays 10s), for both double tap and the keyboard arrows. The timing rules live in tapGestures.ts so they are unit-testable without mounting the player. Rapid double taps now chain off a still-in-flight seek target instead of all resolving against the same not-yet-updated position. TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DOUBLE_TAP_WINDOW_MS,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
} from "./tapGestures";
|
||||
|
||||
const SCREEN_WIDTH = 1000;
|
||||
const LEFT = 100;
|
||||
const RIGHT = 900;
|
||||
|
||||
function tap(state: ReturnType<typeof createTapGestureState>, 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<typeof tap>) {
|
||||
if (outcome.action !== "seek") {
|
||||
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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("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", () => {
|
||||
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 deferred single-tap pause must have been cancelled.
|
||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeks back 10s on a double tap on the left half", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
it("treats a second tap after the window as a new pending single tap", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||
|
||||
expect(late.action).toBe("pending");
|
||||
});
|
||||
|
||||
it("does not treat a third tap as another double tap", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
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() drops a pending tap so an interpreted swipe cannot pause", () => {
|
||||
const state = createTapGestureState();
|
||||
tap(state, RIGHT, 1000);
|
||||
state.cancel();
|
||||
|
||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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 to the duration when skipping past the end", () => {
|
||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user