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:
2026-07-30 12:52:03 +02:00
parent 984e594006
commit 98a6bca645
5 changed files with 87 additions and 8 deletions
+5 -1
View File
@@ -24,6 +24,7 @@
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
@@ -1147,7 +1148,10 @@
async function handleSeekBarChange(e: Event) {
const input = e.target as HTMLInputElement;
const targetTime = parseFloat(input.value);
// Clamp strictly inside the media: the range input's max IS the duration, so
// dragging fully right would otherwise request a segment past the media end,
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
const targetTime = clampSeekTarget(parseFloat(input.value), duration);
// Set isSeeking immediately to prevent timeupdate from interfering
isSeeking = true;
+45 -2
View File
@@ -6,6 +6,8 @@ import {
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
END_SEEK_MARGIN_SECONDS,
} from "./tapGestures";
const SCREEN_WIDTH = 1000;
@@ -123,8 +125,28 @@ describe("seek target resolution", () => {
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
});
it("clamps to the duration when skipping past the end", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
it("clamps short of the duration when skipping past the end", () => {
// Never land exactly on `duration`: hls.js would then request the segment
// that starts at/after the media end, which the server never produces —
// the fetch times out and the gap-controller stalls in a pause loop.
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
DURATION - END_SEEK_MARGIN_SECONDS
);
});
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
// Regression: seeking near the end of a ~105min transcoded item clamped to
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
// starts at 6336.33s — past the end. That segment 404s/times out forever.
const runtime = 6330.324;
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
expect(target).toBeLessThan(runtime);
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
});
it("does not clamp below zero for media shorter than the end margin", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
});
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
@@ -156,3 +178,24 @@ describe("seek target resolution", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
});
});
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
it("keeps a mid-stream target untouched", () => {
expect(clampSeekTarget(100, 600)).toBe(100);
});
it("pulls a drag to the very end back inside the media", () => {
// The seek bar's max IS the duration, so dragging fully right yields
// exactly `duration` — the value that triggers the dead-segment stall.
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
});
it("clamps negative and non-finite targets to zero", () => {
expect(clampSeekTarget(-5, 600)).toBe(0);
expect(clampSeekTarget(NaN, 600)).toBe(0);
});
it("leaves the target alone when the duration is unknown", () => {
expect(clampSeekTarget(500, 0)).toBe(500);
});
});
+33 -2
View File
@@ -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;
}