Seeking near the end of a transcoded video locked the player into a stall/pause loop: unpausing or skipping bounced straight back to paused. Both seek paths clamped the target to exactly `duration`. hls.js then requested the segment whose start time lies *past* the end of the media (a 6330.324s item asks for segment 1055, starting at 6336.33s). Jellyfin never produces that segment, the fetch times out, and the gap-controller stalls forever at the last buffered position — retrying ~1x/second and firing an endless stream of AbortErrors as play() lands mid-nudge. Clamp strictly inside the media instead, keeping one segment length (6s) of margin, floored at 0 so short media still seeks to the start. The seek-bar drag path needed this too: its range input `max` is the duration itself, so dragging fully right produced the same dead target. Also bumps the requirement-count fixture for the new DR-095 row.
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
/**
|
|
* Tap-gesture interpretation for the video player surface.
|
|
*
|
|
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
|
* a tap cannot be classified at the moment it lands, because it may still turn
|
|
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
|
* until the double-tap window closes, and cancelled outright if a second tap
|
|
* arrives — otherwise a double tap both toggles pause and seeks.
|
|
*
|
|
* TRACES: UR-005, UR-061 | DR-092, DR-095 | UT-085, UT-086, UT-087, UT-088
|
|
*/
|
|
|
|
/** A second tap within this window makes a double tap. */
|
|
export const DOUBLE_TAP_WINDOW_MS = 300;
|
|
|
|
/** 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 =
|
|
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
|
| { action: "pending"; pendingAfterMs: number }
|
|
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
|
|
|
export interface TapInput {
|
|
/** Tap x position, viewport pixels. */
|
|
x: number;
|
|
screenWidth: number;
|
|
now: number;
|
|
}
|
|
|
|
export interface TapGestureState {
|
|
/**
|
|
* Resolve a still-pending single tap. Returns the play/pause action once the
|
|
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
|
* became a double tap, or was cancelled).
|
|
*/
|
|
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
|
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
|
cancel(): void;
|
|
}
|
|
|
|
interface InternalState extends TapGestureState {
|
|
lastTapTime: number;
|
|
pendingSince: number | null;
|
|
}
|
|
|
|
export function createTapGestureState(): TapGestureState {
|
|
const state: InternalState = {
|
|
lastTapTime: 0,
|
|
pendingSince: null,
|
|
resolvePending(now: number) {
|
|
if (state.pendingSince === null) return null;
|
|
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
|
state.pendingSince = null;
|
|
return { action: "togglePlayPause" };
|
|
},
|
|
cancel() {
|
|
state.pendingSince = null;
|
|
state.lastTapTime = 0;
|
|
},
|
|
};
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
|
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
|
* the window returns the seek and clears the pending play/pause.
|
|
*/
|
|
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) {
|
|
// Second tap: cancel the deferred play/pause and seek instead.
|
|
s.pendingSince = null;
|
|
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
|
const isLeftSide = input.x < input.screenWidth / 2;
|
|
return isLeftSide
|
|
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
|
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
|
}
|
|
|
|
s.lastTapTime = input.now;
|
|
s.pendingSince = input.now;
|
|
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|