Files
jellytau/src/lib/player/adapters/html5Adapter.ts
T
dtourolle c0c6c5023e
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00

275 lines
10 KiB
TypeScript

/**
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
* implementation. It owns the high-level control surface for an HTML5 `<video>`
* element and reports the element's lifecycle back into Rust via its
* {@link AdapterHost}.
*
* Design note on the split with VideoPlayer.svelte:
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
* "reload stream" seek/audio-track dance with its dual-audio teardown and
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
* element. Rather than relocate that reactive machinery wholesale (high
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
* callbacks the owning component supplies. The adapter is the single OWNER of the
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
* the seam to the component's element/HLS/reactive state. This keeps all control
* 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, DR-096
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
/**
* Narrow seam the owning component provides so the adapter can execute the
* element/HLS-coupled parts of a control action without re-implementing the
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
* work the component already does.
*/
export interface Html5ElementBridge {
/** The bound <video> element, or null before mount / after teardown. */
getElement(): HTMLVideoElement | null;
/** Current seek offset (seconds) for transcoded streams. */
getSeekOffset(): number;
setSeekOffset(offset: number): void;
/** Update the stream URL the component renders (triggers its HLS $effect). */
setStreamUrl(url: string): void;
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
destroyHls(): void;
/** Media source id for seek/audio-track URLs. */
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;
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
this.host = host;
this.bridge = bridge;
}
/**
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
* component's current reactive `videoElement`, which is authoritative: the
* element can be re-bound when the {#if} block re-renders, so a value captured
* once in `attach()` may go stale (this caused play/pause to silently no-op).
* Falls back to the attach()-captured element for unit tests whose bridge
* returns null.
*/
private get element(): HTMLVideoElement | null {
return this.bridge.getElement() ?? this.attachedElement;
}
attach(element: HTMLVideoElement | null): void {
this.attachedElement = element;
}
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
// The component's reactive HLS $effect performs the actual attach/load when
// the stream URL is set; loading is therefore driven by setStreamUrl. The
// component's canplay/frag-buffered path reports readiness through the host.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(streamUrl);
this.host.onState("loading");
}
async play(): Promise<void> {
const el = this.element;
if (!el) return;
// 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> {
this.element?.pause();
}
async toggle(): Promise<boolean> {
const el = this.element;
if (!el) return false;
if (el.paused) {
await this.play();
return true;
}
await this.pause();
return false;
}
/**
* PRIMITIVE: in-place element seek (no reload). The backend already decided
* this seek does not need a transcode reload.
*/
async seekElement(positionSeconds: number, offset: number): Promise<void> {
const el = this.element;
if (!el) return;
el.currentTime = positionSeconds;
this.bridge.setSeekOffset(offset);
await this.waitForEvent(el, "seeked", 2000);
}
/**
* PRIMITIVE: compound reload — swap the source and resume at
* `positionSeconds`, an **absolute** position on the item's own timeline.
* Contains NO strategy decision; the backend already decided to reload and
* supplied the url/position. Preserves the hard-won dual-audio teardown and
* canplay wait.
*
* The position is reached by *seeking the element*, and the transcode offset
* is cleared to zero. It used to be the other way round — the offset was set
* to the position and nothing seeked — which was correct only while the
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
* parameter (on an HLS playlist it makes the server reject every segment with
* `400`), so a reloaded stream now always starts at the beginning of the item.
* Leaving the old arithmetic in place would have left `currentTime` reading
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
* no seek ever happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
async reloadSource(url: string, positionSeconds: number): Promise<void> {
const el = this.element;
if (!el) {
// Still update the stream URL so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
return;
}
const wasPlaying = !el.paused;
el.pause();
this.bridge.destroyHls();
if (el.src) {
el.removeAttribute("src");
el.load();
}
await new Promise((r) => setTimeout(r, 100));
// The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url);
// A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert
// its selection and surface the error instead of leaving the UI claiming a
// stream that is not playing.
const ready = await this.waitForEvent(el, "canplay", 10000);
if (!ready) {
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
}
// Now that the new source is playable, put it where the caller asked for.
// Seeking before `canplay` is dropped by the element, which is why this
// follows the wait rather than riding along with the URL swap.
if (positionSeconds > 0) {
el.currentTime = positionSeconds;
await this.waitForEvent(el, "seeked", 2000);
}
if (wasPlaying) await el.play();
}
setVolume(volume: number): void {
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
}
setMuted(muted: boolean): void {
if (this.element) this.element.muted = muted;
}
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
const el = this.element;
if (!el || !el.textTracks) return;
for (let i = 0; i < el.textTracks.length; i++) {
el.textTracks[i].mode = "disabled";
}
if (streamIndex !== null) {
const tracks = el.querySelectorAll("track");
tracks.forEach((track) => {
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
if (idx === streamIndex && track.track) {
track.track.mode = "showing";
}
});
}
}
getPosition(): number {
const el = this.element;
if (!el) return 0;
return el.currentTime + this.bridge.getSeekOffset();
}
async dispose(): Promise<void> {
this.bridge.destroyHls();
const el = this.element;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
this.attachedElement = null;
}
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
/**
* Resolves `true` when the event fires, `false` if the budget runs out. The
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
* missing `canplay` means the reload failed.
*/
private waitForEvent(
el: HTMLVideoElement,
event: string,
timeoutMs: number
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let timer: ReturnType<typeof setTimeout>;
const done = (fired: boolean) => {
el.removeEventListener(event, listener);
clearTimeout(timer);
resolve(fired);
};
const listener = () => done(true);
el.addEventListener(event, listener);
timer = setTimeout(() => done(false), timeoutMs);
});
}
}