- 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.
557 lines
25 KiB
TypeScript
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;
|
|
}
|
|
}
|