Files
siftlode/frontend/src/lib/hlsEngine.ts
T
peter 0a35f2a3b1 fix(player): a stale session answer can no longer stop the playback that won
- The engine tracks the request out per item: A, then B, then back to A sent a second request
  for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
  request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
  outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
  previous item under the next one's title.
2026-10-07 23:16:25 +02:00

557 lines
25 KiB
TypeScript

import type { PlexPlaySession } from "./api";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
import type { HlsLevelInfo } from "./plexStats";
// The Plex player's session machine: which server session the <video> is playing, what a seek or a
// track change asks the server for, and which answers still belong to anything. Plain TypeScript with
// its collaborators injected (the session API, hls.js behind `HlsPort`, the media element), so every
// race it exists to win is a unit test without a DOM (hlsEngine.test.ts); PlexPlayer only renders it.
//
// The server keeps ONE HLS session per viewer per item and kills the previous ffmpeg on every new
// request for it. So the engine keeps at most one request per item in flight: a seek, track change or
// reclaim asked for while one is out replaces the QUEUED target, and the queue is sent once when the
// in-flight answer settles — that answer is then stale and dropped. Each request used to go out at
// once: twenty presses of ← at key-repeat pace past the buffer were twenty requests, twenty ffmpeg
// spawns, and five seconds before the picture moved again.
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see `onHlsError`).
export const MAX_SESSION_RECLAIMS = 2;
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
// the first fragment of any session, and a reclaimed session essentially always delivers one —
// `start_session` does not return until its first segment exists. So both cases `onHlsError` names
// restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
export const RECLAIM_WINDOW_MS = 120_000;
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
/** What the player renders from the engine. A new object whenever any of it changes. */
export interface EngineSnapshot {
/** The current session's media can play (its manifest parsed / its metadata loaded). */
ready: boolean;
error: EngineError | null;
/** The current session re-encodes the video: its cold start is honestly slower ("Preparing…"). */
transcode: boolean;
/** The current session's real media start, lag-compensated — the subtitle shift keys off it. */
sessionK: number;
}
/** A fatal-or-not hls.js error, reduced to what the engine decides on. */
export interface HlsFailure {
fatal: boolean;
network: boolean;
status: number | null;
type: string;
details: string;
}
/** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */
export interface HlsPort {
load(url: string, media: HTMLMediaElement): void;
destroy(): void;
getAudioTrack(): number;
setAudioTrack(track: number): void;
levelInfo(): HlsLevelInfo;
}
export interface HlsHandlers {
onManifestParsed(): void;
onAudioTracksUpdated(): void;
onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void;
onError(e: HlsFailure): void;
}
/** The track parameters a session request carries, read when it is SENT (a queued request goes out
* with whatever the viewer has picked by then). */
export interface SessionParams {
/** ≥2 audio tracks: one multi-rendition session, audio switched client-side. */
multi: boolean;
/** The single-rendition session's audio ordinal (ignored when `multi`). */
audio: number | null;
/** The A/V offset, seconds. */
aoff: number;
}
export interface EngineDeps {
media(): HTMLMediaElement | null;
requestSession(id: string, startAt: number, p: SessionParams): Promise<PlexPlaySession>;
/** Tell the server a session is no longer wanted (its ffmpeg stops at once). Fire-and-forget. */
stopSession(id: string, sessionId: string): void;
/** A new hls.js instance, or null where hls.js cannot run (the media plays the URL natively). */
createHls(handlers: HlsHandlers): HlsPort | null;
params(): SessionParams;
/** Where the viewer is, absolute seconds (0 = not ticked yet). */
position(): number;
/** The item's duration, seconds (0 = unknown). */
duration(): number;
now(): number;
/** An HTTP status from a failed session request (undefined = not an HTTP error). */
statusOf(e: unknown): number | undefined;
/** A request for a session went out: the player clears its stall spinner. */
onLoadStart?(): void;
onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void;
onHlsError?(e: HlsFailure): void;
}
/** Play intent while a session request is in flight. `said`: the viewer changed it since. */
interface Intent {
play: boolean;
said?: boolean;
}
// What an engine does before `connect`: nothing. It is created during render, where its owner may not
// hand it anything that reads a ref, and connected from a layout effect before any effect can use it.
const UNCONNECTED: EngineDeps = {
media: () => null,
requestSession: () => new Promise(() => {}),
stopSession: () => {},
createHls: () => null,
params: () => ({ multi: false, audio: null, aoff: 0 }),
position: () => 0,
duration: () => 0,
now: () => 0,
statusOf: () => undefined,
};
interface Request {
id: string;
startAt: number;
intent: Intent;
gen: number;
}
export class HlsEngine {
/** The absolute offset the attached session begins at (a ref-shaped box: hooks read `.current`). */
readonly sessionStart = { current: 0 };
/** Which encoder the current transcode ran: true = VAAPI, false = libx264, null = not a transcode. */
hw: boolean | null = null;
/** The current HW transcode tone-mapped HDR→SDR on the GPU. */
tonemap: boolean | null = null;
private deps: EngineDeps;
private snapshot: EngineSnapshot = { ready: false, error: null, transcode: false, sessionK: 0 };
private readonly listeners = new Set<() => void>();
private alive = true;
// Which request is the current one. Everything after a request's `await` checks it: an answer
// that lands after a newer request, an item change or the player's close is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to
// the detached <video> after Back, never destroyed, keeping the server session warm.
private gen = 0;
private hls: HlsPort | null = null;
// The server session the media is playing (HLS, by hls.js or natively): what `stopSession` names on
// the way out.
private attachedSession: { id: string; sessionId: string } | null = null;
// Removes the media listeners the current session put on the <video>. A newer session, an item
// change and the close all call it: the generation guards the `await`, and these fire LATER — a
// superseded direct-play item's loadedmetadata used to run on the next item's media and seek it to
// the old item's resume point, or start the old media playing under the new title.
private detachSession: () => void = () => {};
// Whether the media's clock belongs to the session `sessionStart` describes. False from the moment
// a session is asked for until it has answered: the media still attached meanwhile is the OLD one,
// so `sessionStart + currentTime` was the previous item's position — sent as the new item's subtitle
// position and saved as its progress, or, during a seek-restart, the pre-seek spot over the target.
private live = false;
// The play intent of the session being asked for, null while none is out. Until the answer
// attaches, the media is the OUTGOING one, and a play() there sounded the old stream while the new
// one still attached paused.
private pending: Intent | null = null;
// The item whose session is attached to the media right now (see the failure path).
private attachedId: string | null = null;
// The attached session's play intent, which stands until it is ready (see `wantsPlay`).
private attachedIntent: Intent | null = null;
// The request out for each item. Per item, not one slot: another item's request took the slot, and
// the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
// Where the current — or still in-flight — session was ASKED to start. Written before the request
// goes out, so "where the viewer is" has an answer before the first timeupdate.
private loadStart = 0;
constructor(deps: EngineDeps = UNCONNECTED) {
this.deps = deps;
}
/** Hand the engine its collaborators (see UNCONNECTED). */
connect(deps: EngineDeps): void {
this.deps = deps;
}
// --- the React side ------------------------------------------------------------------------
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getSnapshot = (): EngineSnapshot => this.snapshot;
private set(patch: Partial<EngineSnapshot>): void {
const next = { ...this.snapshot, ...patch };
const same = (Object.keys(next) as (keyof EngineSnapshot)[]).every(
(k) => next[k] === this.snapshot[k],
);
if (same) return;
this.snapshot = next;
this.listeners.forEach((fn) => fn());
}
/** The media's clock is the current session's (see `live`). */
get clockLive(): boolean {
return this.live;
}
/** The video level hls.js plays, or null for a direct stream. */
levelInfo(): HlsLevelInfo | null {
return this.hls ? this.hls.levelInfo() : null;
}
/** The player mounted (again — StrictMode replays a mount): answers may land on it once more. */
revive(): void {
this.alive = true;
}
/** The player closed: nothing in flight lands any more, the stream and its server session go,
* and the <video> stops — a late play() must not leave audio playing on a detached element. The
* element is the caller's: React has detached its ref by the time an unmount cleanup runs. */
destroy(media: HTMLMediaElement | null): void {
this.alive = false;
this.leaveItem();
if (!media) return;
try {
media.pause();
media.removeAttribute("src");
media.load();
} catch {
/* ignore */
}
}
/** The page is being unloaded for good (not kept in the back/forward cache): the server session
* stops now, since no effect cleanup runs on an unload. */
pageHidden(persisted: boolean): void {
const s = this.attachedSession;
if (persisted || !s) return;
this.attachedSession = null;
this.deps.stopSession(s.id, s.sessionId);
}
/** A fresh item is a fresh incident: the reclaim budget must not carry over from the previous one
* (the player stays mounted across a queue advance). */
newItem(): void {
this.reclaims = { n: 0, at: 0 };
}
/** Leaving the item: a session still in flight for it must not land after it, its stream goes,
* and the server stops producing it. */
leaveItem(): void {
this.gen++; // a queued request of this item is now stale, and is never sent
this.detachSession();
this.detachSession = () => {};
this.dropHls();
}
/** The viewer is moving to another item: the media's clock stops being trusted at once (the
* timeupdate the outgoing pause() queues lands after the reset and would hand the old position on). */
releaseClock(): void {
this.live = false;
}
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). */
start(id: string, startAt: number, play = true): void {
if (!this.deps.media()) return;
const gen = ++this.gen;
this.detachSession();
this.detachSession = () => {};
this.live = false;
const intent: Intent = { play };
this.pending = intent;
this.loadStart = startAt;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
const req: Request = { id, startAt, intent, gen };
if (this.inFlight.has(id)) this.queued = req;
else this.send(req);
}
private send(req: Request): void {
this.queued = null;
this.inFlight.set(req.id, req);
void this.deps.requestSession(req.id, req.startAt, this.deps.params()).then(
(sess) => this.answered(req, sess),
(e: unknown) => this.failed(req, e),
);
}
private current(req: Request): boolean {
return req.gen === this.gen && this.alive;
}
/** The in-flight request settled: the queued one goes now, unless it is stale too (the viewer
* left the item, or the player closed). */
private settledInFlight(req: Request): void {
if (this.inFlight.get(req.id) === req) this.inFlight.delete(req.id);
const next = this.queued;
if (!next || this.inFlight.has(next.id)) return;
this.queued = null;
if (this.alive && next.gen === this.gen) this.send(next);
}
private failed(req: Request, e: unknown): void {
this.settledInFlight(req);
if (!this.current(req)) return; // superseded or left: not this request's error to show
if (this.pending === req.intent) this.pending = null;
// A failed seek-restart leaves the old session attached and its clock still true; a failed move
// to another item leaves the previous item's media, which is not this item's clock.
this.live = this.attachedId === req.id;
// Say WHY playback can't start instead of an eternal spinner. 404 = the physical file isn't
// reachable (a missing media mount or a wrong path map); 501 = the codec needs a full transcode.
const status = this.deps.statusOf(e);
this.set({ error: status === 501 ? "transcode" : status === 404 ? "notFound" : "generic" });
}
private answered(req: Request, sess: PlexPlaySession): void {
this.settledInFlight(req);
if (!this.current(req)) {
// Nobody will play it: say so, or its ffmpeg runs on until the reaper. The server matches the
// id, so a newer session for the same item that has replaced it meanwhile is left alone.
if (sess.mode === "hls" && sess.id) this.deps.stopSession(req.id, sess.id);
return;
}
const media = this.deps.media();
if (!media) return;
const { intent, startAt, id } = req;
const isTranscode = sess.mode === "hls" && !!sess.transcode;
this.hw = isTranscode ? (sess.hw ?? false) : null;
this.tonemap = isTranscode ? (sess.tonemap ?? false) : null;
// hls.js plays a non-zero-start copyts stream ~1 s BEHIND the media clock (see HLS_SUB_LAG); fold
// that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct play (start_s 0) uses the file's real clock.
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
this.sessionStart.current = trueStart;
// From here the media's clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
this.live = true;
this.attachedId = id;
this.attachedIntent = intent;
if (this.pending === intent) this.pending = null;
this.set({ transcode: isTranscode, sessionK: trueStart });
// Belt and braces: the play() on MANIFEST_PARSED / loadedmetadata can be rejected when the media
// is not ready yet (slow first segments over a proxy), leaving the video paused after a seek the
// viewer was watching through. Retried once the element can play; the intent is read when it
// fires, since the viewer may have asked for play or pause meanwhile (`requestPlay` keeps it).
this.listen(media, req, "canplay", () => {
if (intent.play && media.paused) media.play().catch(() => {});
});
// The server replaced the previous HLS session for this item already — unless this answer is a
// direct stream (an A/V offset back at 0), which replaces nothing there.
this.dropHls(sess.mode === "hls" ? id : undefined);
// Named for the stop on the way out whoever plays it: hls.js, or Safari's native HLS below.
if (sess.mode === "hls" && sess.id) this.attachedSession = { id, sessionId: sess.id };
const hls = sess.mode === "hls" ? this.deps.createHls(this.hlsHandlers(req, media)) : null;
if (hls) {
this.hls = hls;
hls.load(`${sess.url}?t=${Math.floor(startAt)}`, media);
return;
}
// A direct file (or Safari's native HLS).
media.src = sess.url;
media.load();
this.listen(media, req, "loadedmetadata", () => {
if (sess.mode === "direct" && startAt > 0) media.currentTime = startAt;
this.set({ ready: true });
if (intent.play) media.play().catch(() => {});
});
}
/** A once-listener on the media that runs only while `req` is the current session. */
private listen(media: HTMLMediaElement, req: Request, type: string, fn: () => void): void {
const guarded = () => {
if (this.current(req)) fn();
};
media.addEventListener(type, guarded, { once: true });
const prev = this.detachSession;
this.detachSession = () => {
prev();
media.removeEventListener(type, guarded);
};
}
/** Destroy the hls.js instance, and stop the attached server session unless it belongs to
* `keepFor` (the server replaces a viewer's session for one item by itself). */
private dropHls(keepFor?: string): void {
const s = this.attachedSession;
this.attachedSession = null;
if (s && s.id !== keepFor) this.deps.stopSession(s.id, s.sessionId);
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
}
private hlsHandlers(req: Request, media: HTMLMediaElement): HlsHandlers {
return {
// Only the current session's: an instance replaced before its manifest parsed (a restart asked
// for right after an attach) marked the player ready while the next session was still being
// asked for — so that one then attached past `wantsPlay`'s not-ready guard — and played the
// outgoing media.
onManifestParsed: () => {
if (!this.current(req)) return;
this.set({ ready: true });
if (req.intent.play) media.play().catch(() => {});
},
// The selected audio is restored once hls.js has the renditions: on MANIFEST_PARSED the list
// was not populated yet, the choice was dropped, and the default track played under the
// restored selection.
onAudioTracksUpdated: () => {
const p = this.deps.params();
if (!p.multi || !this.hls) return;
const want = p.audio ?? 0;
if (this.hls.getAudioTrack() === want) return;
try {
this.hls.setAudioTrack(want);
} catch {
/* out of range — ignore */
}
},
onFragLoaded: (frag) => this.deps.onFragLoaded?.(frag),
onError: (e) => this.onHlsError(req, media, e),
};
}
// A fatal HLS error (the session died, the file became unreadable) would otherwise leave the
// spinner up forever — it is said instead.
//
// Except a 404: the session is gone, not broken. Either the server reaped it (a pause longer than
// its idle limit, a deploy) — hls.js never retries a 4xx, so this used to end playback with
// "couldn't play" after the buffer ran out — or it was superseded before it ever played (two
// requests for one item: the server's LAST spawn wins). The server answers a superseded session
// exactly so the client asks again; the reclaim is that asking.
//
// Bounded by the budget, not by whether the session got as far as playing: that belongs to ONE
// session, and every restart builds a fresh one, so it could never bound a loop. The server also answers
// 404 for "that segment is not there YET" — indistinguishable on the wire — so a transcode falling
// behind realtime restarted ffmpeg forever behind a spinner that never became an error.
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return;
if (e.network && e.status === 404) {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
// at the window's start, the window tumbled instead of rolling, and any loss period longer
// than half the window reset the budget on every second loss (measured: 200 losses 70 s apart
// → 200 reclaims, never an error).
if (now - this.reclaims.at > RECLAIM_WINDOW_MS) this.reclaims.n = 0;
this.reclaims.at = now;
if (this.reclaims.n < MAX_SESSION_RECLAIMS) {
this.reclaims.n += 1;
// The server dropped it already; nothing to stop.
this.attachedSession = null;
this.start(req.id, this.playhead(), this.wantsPlay(media));
return;
}
}
this.set({ error: "generic" });
}
/** Where the viewer is: the ticked position, or — before playback has ticked (a session still
* loading, or a resumed one that has stayed paused) — where the session was ASKED to start.
* Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00
* from 0:00, and the next checkpoint saved that. */
playhead(): number {
const p = this.deps.position();
return p > 0 ? p : this.loadStart;
}
/** The absolute time of the media's clock, or null while it is not the current session's. */
absTime(media: HTMLMediaElement): number | null {
return this.live ? this.sessionStart.current + media.currentTime : null;
}
/** A seek target inside the item: not before 0, not past its last second. */
clamp(target: number): number {
const dur = this.deps.duration() || target;
return Math.max(0, Math.min(target, dur - 1));
}
/** Seek to absolute `target` (clamped): natively inside the attached session's loaded region,
* else a session restart there. `play`: the caller's intent across the seek; else what the media
* was doing. */
seek(id: string, target: number, play?: boolean): void {
const media = this.deps.media();
const T = this.clamp(target);
if (!media) return;
const wasPlaying = play ?? this.wantsPlay(media);
const rel = T - this.sessionStart.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStart` and `seekable` describe the OLD one: a native seek there left the pending
// restart current, and its answer then jumped to the earlier target.
const end = media.seekable.length ? media.seekable.end(0) : 0;
if (this.live && rel >= 0 && rel <= end + 0.5) {
media.currentTime = rel; // keeps playing on its own
if (play) media.play().catch(() => {});
} else {
this.start(id, T, wasPlaying); // only auto-resume if it was playing
}
}
/** Play or pause. While a session is being asked for, that is the pending session's intent: the
* outgoing media is never played, only paused if the viewer asks for a pause. Returns true when
* it went into the intent — the caller then shows `want` itself, the media has not changed. */
requestPlay(want: boolean): boolean {
const media = this.deps.media();
if (!media) return false;
const pending = this.pending;
if (pending) {
pending.play = want;
pending.said = true;
if (!want && !media.paused) media.pause();
return true;
}
// The attached session's intent follows: its manifest and first canplay still play by it, and a
// pause given before they fired was played over.
if (this.attachedIntent) this.attachedIntent.play = want;
if (want) media.play().catch(() => {});
else media.pause();
return false;
}
/** Whether the viewer wants playback, for a restart that carries it over: a session still being
* asked for has its own intent; an attached one keeps the intent it was asked with until it is
* ready, because attaching empties the media — it reads paused until hls.js has parsed and played,
* and a seek or a reclaim in that window took that for the viewer's pause (the next session then
* attached paused). From ready on, the media's own state is the answer. */
private wantsPlay(media: HTMLMediaElement): boolean {
if (this.pending) return this.pending.play;
if (!this.snapshot.ready && this.attachedIntent) return this.attachedIntent.play;
return !media.paused;
}
/** What the viewer sees: the outgoing media's state, until they have said otherwise. */
shownPlaying(): boolean {
const media = this.deps.media();
const pending = this.pending;
return pending?.said ? pending.play : !!media && !media.paused;
}
/** Switch audio client-side on a multi-rendition session. False when there is none attached yet
* (the caller restarts the session with the new track instead). */
setAudioTrack(track: number): boolean {
if (!this.deps.params().multi || !this.hls) return false;
try {
this.hls.setAudioTrack(track);
} catch {
/* ignore */
}
return true;
}
}