Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
237 lines
8.5 KiB
TypeScript
237 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;
|
|
}
|