Files
jellytau/src/lib/components/player/tapGestures.ts
T
dtourolle 98a6bca645 fix(player): clamp seeks inside media to stop end-of-stream pause loop (DR-095)
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.
2026-07-30 12:52:03 +02:00

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;
}