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.
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* 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 | UT-085, UT-086, UT-087, UT-088
|
||||
* 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. */
|
||||
@@ -91,6 +91,33 @@ export function registerTap(state: TapGestureState, input: TapInput): TapOutcome
|
||||
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;
|
||||
@@ -123,6 +150,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
||||
|
||||
const target = base + delta;
|
||||
if (target < 0) return 0;
|
||||
if (duration > 0 && target > duration) return duration;
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user