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.
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
DOUBLE_TAP_WINDOW_MS,
|
|
SEEK_FORWARD_SECONDS,
|
|
SEEK_BACKWARD_SECONDS,
|
|
createTapGestureState,
|
|
registerTap,
|
|
resolveSeekTarget,
|
|
clampSeekTarget,
|
|
END_SEEK_MARGIN_SECONDS,
|
|
} from "./tapGestures";
|
|
|
|
const SCREEN_WIDTH = 1000;
|
|
const LEFT = 100;
|
|
const RIGHT = 900;
|
|
|
|
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
|
|
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
|
|
}
|
|
|
|
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
|
|
function asSeek(outcome: ReturnType<typeof tap>) {
|
|
if (outcome.action !== "seek") {
|
|
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
|
|
}
|
|
return outcome;
|
|
}
|
|
|
|
describe("tap gesture resolution", () => {
|
|
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
|
const state = createTapGestureState();
|
|
const first = tap(state, RIGHT, 1000);
|
|
|
|
// The first tap must NOT immediately toggle play/pause — it may still
|
|
// become a double tap.
|
|
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
|
});
|
|
|
|
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
|
|
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
|
expect(resolved).toEqual({ action: "togglePlayPause" });
|
|
});
|
|
|
|
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
const second = asSeek(tap(state, RIGHT, 1150));
|
|
|
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
|
expect(second.seekSeconds).toBe(30);
|
|
expect(second.feedback).toBe("right");
|
|
|
|
// The deferred single-tap pause must have been cancelled.
|
|
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
|
});
|
|
|
|
it("seeks back 10s on a double tap on the left half", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, LEFT, 1000);
|
|
const second = asSeek(tap(state, LEFT, 1100));
|
|
|
|
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
|
expect(second.seekSeconds).toBe(-10);
|
|
expect(second.feedback).toBe("left");
|
|
});
|
|
|
|
it("treats a second tap after the window as a new pending single tap", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
|
|
|
expect(late.action).toBe("pending");
|
|
});
|
|
|
|
it("does not treat a third tap as another double tap", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
|
|
|
// Triple tap: the third tap starts a fresh pending tap rather than
|
|
// seeking again off the consumed second tap.
|
|
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
|
});
|
|
|
|
it("accumulates repeated double taps on the same side", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
const a = asSeek(tap(state, RIGHT, 1100));
|
|
tap(state, RIGHT, 1200);
|
|
const b = asSeek(tap(state, RIGHT, 1300));
|
|
|
|
expect(a.seekSeconds).toBe(30);
|
|
expect(b.seekSeconds).toBe(30);
|
|
});
|
|
|
|
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, LEFT, 1000);
|
|
const second = asSeek(tap(state, RIGHT, 1100));
|
|
|
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
|
expect(second.feedback).toBe("right");
|
|
});
|
|
|
|
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
|
const state = createTapGestureState();
|
|
tap(state, RIGHT, 1000);
|
|
state.cancel();
|
|
|
|
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("seek target resolution", () => {
|
|
const DURATION = 600;
|
|
|
|
it("adds the delta to the reported position", () => {
|
|
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
|
|
});
|
|
|
|
it("clamps to zero when rewinding past the start", () => {
|
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
|
});
|
|
|
|
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", () => {
|
|
// The player has not yet reported the first seek's result, so the
|
|
// reported position is still the pre-seek value.
|
|
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
|
|
const second = resolveSeekTarget({
|
|
delta: 30,
|
|
reportedPosition: 100,
|
|
duration: DURATION,
|
|
pendingTarget: first,
|
|
});
|
|
|
|
expect(second).toBe(160);
|
|
});
|
|
|
|
it("ignores a pending target once the player has caught up past it", () => {
|
|
const target = resolveSeekTarget({
|
|
delta: 30,
|
|
reportedPosition: 200,
|
|
duration: DURATION,
|
|
pendingTarget: 130,
|
|
});
|
|
|
|
expect(target).toBe(230);
|
|
});
|
|
|
|
it("falls back to the delta alone when duration is unknown", () => {
|
|
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);
|
|
});
|
|
});
|