Files
jellytau/src/lib/components/player/tapGestures.ts
T
dtourolle b12e99b7e1
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
fix(player): keep double-tap seek working over the play overlay (DR-098)
The control-surface guard added in the previous commit killed
double-tap-to-seek. The first tap pauses, which renders the full-screen
<button> play overlay over the video, so the SECOND tap lands on a
button — and the guard discarded it as "a tap on a control".

Mark that overlay `data-player-surface`: visually it IS the video, so it
must keep taking tap gestures despite being a <button>. The marker wins
over the interactive-tag check in isControlSurfaceTouch.

Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component
and dispatches real touch/click events at whatever element is genuinely
on top. This is the gap that let four bugs ship in a row: the pure-unit
tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all
passed throughout, because each helper behaved exactly as specified —
every bug was in the composition, i.e. which element actually receives a
tap after Svelte re-renders. Modelling that DOM by hand in a test would
just re-encode the same wrong assumption, so these render it instead.

The new double-tap test was verified to fail with the fix reverted and
pass with it applied, in both directions.
2026-07-30 15:44:38 +02:00

235 lines
8.5 KiB
TypeScript

/**
* Tap-gesture interpretation for the video player surface.
*
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
* deferral:
*
* 1st tap: toggle play/pause
* 2nd tap (within the window): 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 started — playing stays playing, paused stays paused.
*
* This replaced a design that deferred the first tap behind a 300ms timer so it
* could be cancelled if a second tap arrived. That deferral raced the
* compatibility `click` Android's WebView synthesizes after a touch tap: the
* timer cleared its own handle *before* running the toggle, reopening the guard
* that was meant to suppress the late click, which then toggled a second time.
* The result was a play/pause loop about a second apart. Acting immediately
* removes the timer, the window race, and the loop.
*
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
*/
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300;
/**
* How long after a touch tap a mouse `click` is assumed to be the compatibility
* event the browser synthesizes from that touch. Android's WebView can deliver it
* noticeably late, so this is generous.
*/
export const TOUCH_CLICK_SUPPRESS_MS = 700;
/**
* Whether a touch landed on an interactive control rather than the bare video
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
*
* The gesture listener sits on the outer container, and touch events bubble, so
* without this a tap on the bottom control bar runs the gesture handler (toggle
* #1) *and* the button's own click handler (toggle #2) — the two cancel out and
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
* inside an element marked `data-player-controls` are treated as controls.
*
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
* testable without a DOM.
*/
export function isControlSurfaceTouch(
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
): boolean {
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
for (const node of ancestors) {
// `data-player-surface` wins over the tag check: the full-screen play overlay
// is a <button> but is visually the video itself, and must keep taking tap
// gestures — otherwise the second tap of a double tap (which lands on it,
// because the first tap paused and raised it) is discarded and seeking dies.
if (node.isPlayerSurface === true) return false;
if (node.isPlayerControls === true) return true;
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
}
return false;
}
/**
* Whether a `click` should be ignored because a touch tap already handled it.
*
* EVERY click target layered over the video must consult this — not just the
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
* synthesized click lands on *that* button rather than the video, and an
* unguarded handler there re-toggles and undoes the pause (pause appeared
* impossible while unpause worked, because unpausing removes the overlay).
*
* `detail === 0` catches the synthesized click on engines that report it; the
* recency check covers engines that report a real `detail`.
*/
export function isSynthesizedTouchClick(
detail: number,
now: number,
lastTouchTapAt: number
): boolean {
if (detail === 0) return true;
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
}
/** Double tap on the right half: skip forward. */
export const SEEK_FORWARD_SECONDS = 30;
/** Double tap on the left half: skip back. */
export const SEEK_BACKWARD_SECONDS = -10;
export type TapFeedback = "left" | "right";
export type TapOutcome =
/** First tap: toggle play/pause right now. */
| { action: "togglePlayPause" }
/**
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
* undone and the play state survives the double tap unchanged.
*/
| {
action: "seek";
seekSeconds: number;
feedback: TapFeedback;
togglePlayPause: true;
};
export interface TapInput {
/** Tap x position, viewport pixels. */
x: number;
screenWidth: number;
now: number;
}
export interface TapGestureState {
/**
* Forget the previous tap, so the next one is treated as a first tap. Used
* when the gesture turns out to be a swipe.
*/
cancel(): void;
}
interface InternalState extends TapGestureState {
lastTapTime: number;
}
export function createTapGestureState(): TapGestureState {
const state: InternalState = {
lastTapTime: 0,
cancel() {
state.lastTapTime = 0;
},
};
return state;
}
/**
* Classify a tap and return the action to perform *now*.
*
* A tap that closely follows another is the second of a pair: it seeks and
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
* first tap and simply toggles. Nothing is deferred, so there is no window to
* race and no third-tap case — a consumed pair resets the state.
*/
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
const s = state as InternalState;
const sinceLastTap = input.now - s.lastTapTime;
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
const isLeftSide = input.x < input.screenWidth / 2;
return isLeftSide
? {
action: "seek",
seekSeconds: SEEK_BACKWARD_SECONDS,
feedback: "left",
togglePlayPause: true,
}
: {
action: "seek",
seekSeconds: SEEK_FORWARD_SECONDS,
feedback: "right",
togglePlayPause: true,
};
}
s.lastTapTime = input.now;
return { action: "togglePlayPause" };
}
/**
* Safety margin (seconds) kept between a clamped seek target and the media end.
*
* Landing *exactly* on `duration` makes hls.js request the segment whose start
* time is at/after the end of the media. The server never produces that segment,
* so the fetch times out and hls.js' gap-controller stalls forever at the last
* buffered position — surfacing as "unpausing bounces straight back to paused".
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
* the final segment boundary.
*/
export const END_SEEK_MARGIN_SECONDS = 6;
/**
* Clamp an absolute seek target into the safely-playable range.
*
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
* drag path, which can otherwise land exactly on `duration` because the range
* input's `max` is the duration itself.
*/
export function clampSeekTarget(target: number, duration: number): number {
if (!Number.isFinite(target) || target < 0) return 0;
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}
export interface SeekTargetInput {
/** Relative offset in seconds (negative rewinds). */
delta: number;
/** Latest position reported by the player — the authoritative source. */
reportedPosition: number;
/** Media duration; 0/unknown disables the upper clamp. */
duration: number;
/**
* Target of a seek already requested but not yet reflected in
* `reportedPosition`. Consecutive double taps chain off this so they add up
* instead of all resolving against the same stale position.
*/
pendingTarget?: number | null;
}
/**
* Resolve a relative skip to the absolute position the facade expects.
*
* The player facade seeks by absolute position only (the backend picks the seek
* strategy), so the delta is applied here — against the pending target when one
* is still in flight and still ahead of what the player has reported.
*/
export function resolveSeekTarget(input: SeekTargetInput): number {
const { delta, reportedPosition, duration, pendingTarget } = input;
const base =
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
? pendingTarget
: reportedPosition;
const target = base + delta;
if (target < 0) return 0;
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
// going negative on media shorter than the margin itself.
if (duration > 0 && target > duration) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}