634 lines
29 KiB
TypeScript
634 lines
29 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 lost 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;
|
|
|
|
// A server that is restarting (a deploy) is away, not broken: the gateway in front of it answers
|
|
// 502/503/504 while the app comes back, and nothing at all may answer (0). The stream it served is
|
|
// gone with it, but the buffer keeps playing — so the engine asks again where the viewer is, less and
|
|
// less often, and says it cannot play only once the server has been away this long. It used to say
|
|
// so at the first 502, over a picture still playing from the buffer, and never asked again.
|
|
export const AWAY_WINDOW_MS = 180_000;
|
|
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
|
|
const SERVER_AWAY = new Set([0, 502, 503, 504]);
|
|
// The server's answer for a session it no longer has; its 404 means a segment not there yet.
|
|
const SESSION_GONE = 410;
|
|
|
|
/** 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;
|
|
/** Run `fn` after `ms`; the returned function cancels it. */
|
|
later(fn: () => void, ms: number): () => void;
|
|
/** An HTTP status from a failed session request (0 = nothing answered; 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,
|
|
later: () => () => {},
|
|
statusOf: () => undefined,
|
|
};
|
|
|
|
interface Request {
|
|
id: string;
|
|
startAt: number;
|
|
intent: Intent;
|
|
gen: number;
|
|
/** Asked for while the attached stream still plays from its buffer (see `start`). */
|
|
quiet: boolean;
|
|
}
|
|
|
|
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 lost may be reclaimed (see `onHlsError`).
|
|
private reclaims = { n: 0, at: 0 };
|
|
// Since when the server has been away, and how often it was asked since (see `serverAway`).
|
|
private away: { since: number; tries: number } | null = null;
|
|
private cancelRetry: () => void = () => {};
|
|
// 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 };
|
|
this.away = null;
|
|
}
|
|
|
|
/** 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.cancelRetry();
|
|
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). `quiet`: the attached stream is
|
|
* still playing from its buffer (the server went away or lost its session), so it stays the player's until
|
|
* the answer replaces it — no "Loading…" over a running picture, its clock still true. */
|
|
start(id: string, startAt: number, play = true, quiet = false): void {
|
|
if (!this.deps.media()) return;
|
|
this.cancelRetry(); // this request is the one a pending retry would have sent
|
|
const gen = ++this.gen;
|
|
this.detachSession();
|
|
this.detachSession = () => {};
|
|
const intent: Intent = { play };
|
|
this.pending = intent;
|
|
this.loadStart = startAt;
|
|
if (quiet) {
|
|
this.set({ error: null });
|
|
} else {
|
|
this.live = false;
|
|
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, quiet };
|
|
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.
|
|
// A server that is away is asked again instead, for a while.
|
|
const status = this.deps.statusOf(e);
|
|
if (status !== undefined && SERVER_AWAY.has(status) && this.serverAway(req)) return;
|
|
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;
|
|
this.away = null; // the server answered: a later absence is a new one
|
|
const { intent, startAt, id } = req;
|
|
// The stream that kept playing is replaced from here: the new one loads like any other.
|
|
if (req.quiet) this.set({ ready: false });
|
|
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 file became unreadable) would otherwise leave the spinner up forever — it
|
|
// is said instead.
|
|
//
|
|
// Except a session the server no longer has (410): gone, not broken. Either the server reaped it
|
|
// or restarted, or it was superseded before it ever played (two requests for one item: the
|
|
// server's LAST spawn wins), and the reclaim is the asking again that answer wants. It does not
|
|
// wait for hls.js to give up. On a finished playlist, with one video variant and nothing to
|
|
// switch to, it retries even a 4xx, six times over about half a minute (measured: seven requests,
|
|
// then the reclaim), and in a throttled page once a minute (four minutes). On a growing one it
|
|
// takes the segment for a gap and moves on, so the error is never fatal. Either way the first
|
|
// answer is reported. Quiet, since the buffer is still playing: the picture runs on until the
|
|
// answer, which restarts from where it was asked, so what played meanwhile is replayed (accepted:
|
|
// a brief loading state marks it, and nothing the viewer has saved is lost).
|
|
//
|
|
// And except a fatal 404: hls.js gave up on something the server still does not have, and asking
|
|
// again is all that is left.
|
|
//
|
|
// Both are 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. 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 (!this.current(req)) return;
|
|
if (e.network && e.status === SESSION_GONE) {
|
|
this.reclaim(req, media, this.snapshot.ready);
|
|
return;
|
|
}
|
|
if (!e.fatal) return;
|
|
if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
|
|
if (e.network && e.status === 404) {
|
|
this.reclaim(req, media, false);
|
|
return;
|
|
}
|
|
this.set({ error: "generic" });
|
|
}
|
|
|
|
/** Ask for `req`'s item again where the viewer is, while the budget lasts; then say it cannot play. */
|
|
private reclaim(req: Request, media: HTMLMediaElement, quiet: boolean): void {
|
|
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.set({ error: "generic" });
|
|
return;
|
|
}
|
|
this.reclaims.n += 1;
|
|
// The server dropped it already; nothing to stop.
|
|
this.attachedSession = null;
|
|
this.start(req.id, this.playhead(), this.wantsPlay(media), quiet);
|
|
}
|
|
|
|
/** The server is away (see `AWAY_WINDOW_MS`): ask for `req`'s item again where the viewer is,
|
|
* after a delay that grows with each try. False once it has been away too long — the caller then
|
|
* says it cannot play. A restart the viewer asks for meanwhile (a seek, another item) replaces the
|
|
* retry: `start` and `leaveItem` cancel it, and it is dropped if the generation moved on. */
|
|
private serverAway(req: Request): boolean {
|
|
const now = this.deps.now();
|
|
const away = (this.away ??= { since: now, tries: 0 });
|
|
if (now - away.since > AWAY_WINDOW_MS) return false;
|
|
const ms = AWAY_RETRY_MS[Math.min(away.tries, AWAY_RETRY_MS.length - 1)]!;
|
|
away.tries += 1;
|
|
const gen = this.gen;
|
|
this.cancelRetry();
|
|
this.cancelRetry = this.deps.later(() => {
|
|
this.cancelRetry = () => {};
|
|
const media = this.deps.media();
|
|
if (!this.alive || this.gen !== gen || !media) return;
|
|
// Quiet while what was attached had played: its buffer may still be running.
|
|
this.start(req.id, this.playhead(), this.wantsPlay(media), this.snapshot.ready);
|
|
}, ms);
|
|
return true;
|
|
}
|
|
|
|
/** 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. A quiet request leaves
|
|
// `live` alone, so `pending` is what says one is out.
|
|
const end = media.seekable.length ? media.seekable.end(0) : 0;
|
|
if (this.live && !this.pending && 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;
|
|
}
|
|
}
|