Compare commits
2
Commits
v0.2.1
..
1ae213ff39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae213ff39 | ||
|
|
98a6bca645 |
@@ -246,8 +246,10 @@ Internal architecture, components, and application logic.
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
||||
|
||||
expect(defined.UR).toBe(61);
|
||||
expect(defined.IR).toBe(29);
|
||||
expect(defined.DR).toBe(91);
|
||||
expect(defined.DR).toBe(93);
|
||||
expect(defined.JA).toBe(32);
|
||||
expect(defined.total).toBe(213);
|
||||
expect(defined.total).toBe(215);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError"
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||
* produces it routinely, so it must not reach the player's error channel.
|
||||
*/
|
||||
function isPlayInterruptedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const { name, message } = err as { name?: string; message?: string };
|
||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||
private pendingPlay: Promise<void> | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||
// calls is what turns one stall into an AbortError storm.
|
||||
if (this.pendingPlay) return this.pendingPlay;
|
||||
|
||||
this.pendingPlay = (async () => {
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||
// nudges the element to recover from a stall, which cancels the pending
|
||||
// play promise while the element keeps trying. Surfacing it would report
|
||||
// an error roughly once a second for the duration of the stall.
|
||||
if (isPlayInterruptedError(err)) {
|
||||
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
||||
} else {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.pendingPlay = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingPlay;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user