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
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.
235 lines
8.5 KiB
TypeScript
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;
|
|
}
|