refactor(player): the Plex session machine is a tested engine, one request per item in flight

PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
This commit is contained in:
2026-10-06 22:31:48 +02:00
parent 23f16b69df
commit c1058f1d5a
14 changed files with 1492 additions and 528 deletions
+9
View File
@@ -412,6 +412,7 @@ export interface PlexFacets {
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession)
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
@@ -721,6 +722,14 @@ export const plexApi = {
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
// The player no longer wants this HLS session: its ffmpeg stops now instead of at the idle reaper.
// A keepalive POST, so it also goes out from a page being unloaded. The server only stops it while
// it is still the viewer's registered session for the item.
plexStopSession: (id: string, sessionId: string): void =>
beacon(
`/api/plex/stream/${encodeURIComponent(id)}/session/${encodeURIComponent(sessionId)}/stop`,
{},
),
plexProgressBeacon: (
id: string,
position_seconds: number,
+517
View File
@@ -0,0 +1,517 @@
// The Plex player's session machine, without React, hls.js or a media pipeline: every race it exists
// to win, driven step by step. PlexPlayer's DOM tests hold the same behaviour through the component.
import { describe, it, expect, vi } from "vitest";
import type { PlexPlaySession } from "./api";
import {
HlsEngine,
MAX_SESSION_RECLAIMS,
RECLAIM_WINDOW_MS,
type EngineDeps,
type HlsHandlers,
type HlsPort,
type SessionParams,
} from "./hlsEngine";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
/** A <video> stand-in: the media calls the engine makes, and events the test fires. */
class FakeMedia extends EventTarget {
paused = true;
currentTime = 0;
src = "";
seekEnd = 0;
plays = 0;
get seekable() {
const end = this.seekEnd;
return { length: end > 0 ? 1 : 0, end: () => end };
}
play() {
this.plays++;
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {}
removeAttribute(name: string) {
if (name === "src") this.src = "";
}
fire(type: string) {
this.dispatchEvent(new Event(type));
}
}
class FakeHls implements HlsPort {
loaded: string | null = null;
destroyed = false;
audioTrack = 0;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
}
destroy() {
this.destroyed = true;
}
getAudioTrack() {
return this.audioTrack;
}
setAudioTrack(track: number) {
this.audioTrack = track;
}
levelInfo() {
return { index: 0, total: 1 };
}
lost404() {
this.handlers.onError({
fatal: true,
network: true,
status: 404,
type: "networkError",
details: "fragLoadError",
});
}
}
interface Ask {
id: string;
startAt: number;
params: SessionParams;
resolve(s: PlexPlaySession): Promise<void>;
reject(e: unknown): Promise<void>;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(over: Partial<EngineDeps> = {}) {
const media = new FakeMedia();
const asks: Ask[] = [];
const hlses: FakeHls[] = [];
const stops: [string, string][] = [];
let clock = 1_000_000;
const state = { position: 0, duration: 3000, params: { multi: false, audio: null, aoff: 0 } };
const engine = new HlsEngine({
media: () => media as unknown as HTMLMediaElement,
requestSession: (id, startAt, params) =>
new Promise<PlexPlaySession>((res, rej) => {
asks.push({
id,
startAt,
params,
resolve: async (s) => {
res(s);
await flush();
},
reject: async (e) => {
rej(e);
await flush();
},
});
}),
stopSession: (id, sid) => void stops.push([id, sid]),
createHls: (h) => {
const hls = new FakeHls(h);
hlses.push(hls);
return hls;
},
params: () => state.params,
position: () => state.position,
duration: () => state.duration,
now: () => clock,
statusOf: (e) => (e as { status?: number }).status,
...over,
});
return {
engine,
media,
asks,
hlses,
stops,
state,
advance: (ms: number) => void (clock += ms),
};
}
const hls = (id: string, start_s = 0, sid = `s-${id}-${start_s}`): PlexPlaySession => ({
mode: "hls",
url: `/hls/${id}/index.m3u8`,
start_s,
id: sid,
});
const direct = (id: string): PlexPlaySession => ({
mode: "direct",
url: `/media/${id}`,
start_s: 0,
});
describe("one request per item in flight", () => {
it("queues a restart asked for while the start is out, and sends only the LAST target", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
for (let t = 100; t <= 2000; t += 100) engine.seek("A", t);
expect(asks.map((a) => a.startAt)).toEqual([0]);
await asks[0]!.resolve(hls("A"));
expect(asks.map((a) => a.startAt)).toEqual([0, 2000]);
});
it("drops the stale answer it queued behind — it never attaches", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
engine.seek("A", 500);
await asks[0]!.resolve(hls("A", 0));
expect(hlses).toHaveLength(0);
await asks[1]!.resolve(hls("A", 500));
expect(hlses).toHaveLength(1);
expect(hlses[0]!.loaded).toBe("/hls/A/index.m3u8?t=500");
expect(media.src).toBe("");
});
it("still sends the queued target when the in-flight request fails, and shows no error for it", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 700);
await asks[0]!.reject({ status: 500 });
expect(asks.map((a) => a.startAt)).toEqual([0, 700]);
expect(engine.getSnapshot().error).toBeNull();
});
it("sends another item's request at once, and drops the first item's queue", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
engine.start("B", 0);
expect(asks.map((a) => [a.id, a.startAt])).toEqual([
["A", 0],
["B", 0],
]);
await asks[0]!.resolve(hls("A"));
await asks[1]!.resolve(direct("B"));
expect(asks).toHaveLength(2); // A's 900 is gone with A, whichever answer settles first
});
it("never sends a queued target of an item the viewer left before the next one started", async () => {
// The next item's detail may still be loading: nothing replaced the queue yet.
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
await asks[0]!.resolve(hls("A"));
expect(asks).toHaveLength(1);
});
it("reads the track parameters when the queued request is SENT", async () => {
const { engine, asks, state } = setup();
engine.start("A", 100);
engine.start("A", 100); // an audio switch while the start is out
state.params = { multi: false, audio: 1, aoff: 0 } as never;
await asks[0]!.resolve(hls("A", 100));
expect(asks[1]!.params.audio).toBe(1);
});
});
describe("answers that land late", () => {
it("are dropped after the viewer moved to another item — and the server session is stopped", async () => {
const { engine, asks, hlses, stops } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(hlses).toHaveLength(0);
expect(stops).toEqual([["A", "sA"]]);
});
it("are dropped after the player closed", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
engine.destroy(media as unknown as HTMLMediaElement);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("");
});
it("land again once a StrictMode remount revived the engine", async () => {
const { engine, asks, media } = setup();
engine.destroy(media as unknown as HTMLMediaElement);
engine.revive();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("/media/A");
});
it("a superseded direct item's loadedmetadata does not seek the next one", async () => {
const { engine, asks, media } = setup();
engine.start("A", 1200);
await asks[0]!.resolve(direct("A"));
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
media.fire("loadedmetadata");
expect(media.currentTime).toBe(0);
});
});
describe("the server session", () => {
it("is stopped when the viewer leaves the item, and when the player closes", async () => {
const { engine, asks, stops, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
engine.start("B", 0);
await asks[1]!.resolve(hls("B", 0, "sB"));
engine.destroy(media as unknown as HTMLMediaElement);
expect(stops).toEqual([
["A", "sA"],
["B", "sB"],
]);
});
it("is not stopped by a restart of the same item — the server replaces it itself", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000, "s2"));
expect(stops).toEqual([]);
});
it("is stopped when a direct stream of the same item replaces it", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.start("A", 0); // an A/V offset back at 0 plays the file directly
await asks[1]!.resolve(direct("A"));
expect(stops).toEqual([["A", "s1"]]);
});
it("is stopped when Safari plays it natively too (no hls.js)", async () => {
const { engine, asks, stops, media } = setup({ createHls: () => null });
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(media.src).toBe("/hls/A/index.m3u8");
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
});
it("is stopped on an unload, but not when the page goes into the back/forward cache", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.pageHidden(true);
expect(stops).toEqual([]);
engine.pageHidden(false);
expect(stops).toEqual([["A", "sA"]]);
});
});
describe("a failed session request", () => {
it.each([
[404, "notFound"],
[501, "transcode"],
[500, "generic"],
] as const)("%s says %s", async (status, error) => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status });
expect(engine.getSnapshot().error).toBe(error);
});
it("keeps the old stream's clock after a failed seek-restart, not after a failed move", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
engine.seek("A", 2000);
expect(engine.clockLive).toBe(false);
await asks[1]!.reject({ status: 500 });
expect(engine.clockLive).toBe(true);
engine.leaveItem();
engine.start("B", 0);
await asks[2]!.reject({ status: 404 });
expect(engine.clockLive).toBe(false);
});
});
describe("a session the server no longer has (404)", () => {
it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
state.position = 1299;
hlses[0]!.lost404();
expect(asks[1]!.startAt).toBe(1299);
});
it(`gives up after ${MAX_SESSION_RECLAIMS} reclaims inside the window, and says so`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("rolls the window: losses closer than it keep counting, however slow", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS * 0.6);
}
expect(engine.getSnapshot().error).toBe("generic");
});
it("starts over for a loss long after the last one, and for a new item", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i < 4; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS + 1);
}
expect(engine.getSnapshot().error).toBeNull();
const fresh = setup();
fresh.engine.start("A", 0);
for (let i = 0; i < MAX_SESSION_RECLAIMS; i++) {
await fresh.asks[i]!.resolve(hls("A"));
fresh.hlses[i]!.lost404();
}
fresh.engine.newItem();
await fresh.asks[MAX_SESSION_RECLAIMS]!.resolve(hls("A"));
fresh.hlses[MAX_SESSION_RECLAIMS]!.lost404();
expect(fresh.engine.getSnapshot().error).toBeNull();
});
it("keeps the start's own intent to play when it was lost before it ever played", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.lost404(); // media is still paused: not the viewer's pause
await asks[1]!.resolve(hls("A", 600));
hlses[1]!.handlers.onManifestParsed();
expect(media.plays).toBe(1);
});
});
describe("play intent while a session is asked for", () => {
it("goes into the pending session, and never plays the outgoing media", async () => {
const { engine, asks, media, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.fire("loadedmetadata");
expect(media.paused).toBe(false);
engine.seek("A", 2000, false); // a restart while paused-by-request
expect(engine.requestPlay(false)).toBe(true);
expect(media.paused).toBe(true);
const plays = media.plays;
expect(engine.requestPlay(true)).toBe(true);
expect(media.plays).toBe(plays); // the outgoing stream stays silent
expect(engine.shownPlaying()).toBe(true);
await asks[1]!.resolve(hls("A", 2000));
hlses[0]!.handlers.onManifestParsed();
expect(media.plays).toBe(plays + 1);
});
});
describe("seeking", () => {
it("a restart asked for before the new session has played keeps that session's intent to play", async () => {
// Attaching a new session empties the media: it reads paused until hls.js parses and plays. A
// seek in that window read the paused element as "the viewer paused", and the next session
// attached paused at 0:00 (measured: a burst of ← ending in a frozen frame).
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0));
expect(media.paused).toBe(true); // attached, not played yet
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000));
hlses[1]!.handlers.onManifestParsed();
expect(media.paused).toBe(false);
});
it("is native inside the attached session's loaded region", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 650);
expect(asks).toHaveLength(1);
expect(media.currentTime).toBeCloseTo(650 - (600 - HLS_SUB_LAG));
});
it("restarts past it, and while the clock is not the current session's", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 900);
expect(asks.map((a) => a.startAt)).toEqual([0, 900]);
engine.seek("A", 650); // inside the OLD buffer, but a restart is pending
await asks[1]!.resolve(hls("A", 900));
expect(asks.map((a) => a.startAt)).toEqual([0, 900, 650]);
});
it("clamps the target to the item", () => {
const { engine } = setup();
expect(engine.clamp(-5)).toBe(0);
expect(engine.clamp(99_999)).toBe(2999);
});
});
describe("the session's clock", () => {
it("folds hls.js's lag into a resumed HLS session's start, not a direct one's", async () => {
const { engine, asks } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
expect(engine.getSnapshot().sessionK).toBeCloseTo(600 - HLS_SUB_LAG);
engine.start("A", 0);
await asks[1]!.resolve(direct("A"));
expect(engine.getSnapshot().sessionK).toBe(0);
});
it("is not trusted between asking for a session and its answer", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.currentTime = 50;
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBe(50);
engine.releaseClock();
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBeNull();
});
it("answers 'where the viewer is' with the asked start before playback has ticked", () => {
const { engine, state } = setup();
engine.start("A", 1200);
expect(engine.playhead()).toBe(1200);
state.position = 1250;
expect(engine.playhead()).toBe(1250);
});
});
describe("audio on a multi-rendition session", () => {
it("restores the chosen track once hls.js lists the renditions, and switches client-side", async () => {
const { engine, asks, hlses, state } = setup();
state.params = { multi: true, audio: 1, aoff: 0 } as never;
expect(engine.setAudioTrack(1)).toBe(false); // nothing attached yet: the caller restarts
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onAudioTracksUpdated();
expect(hlses[0]!.audioTrack).toBe(1);
expect(engine.setAudioTrack(0)).toBe(true);
expect(hlses[0]!.audioTrack).toBe(0);
expect(asks).toHaveLength(1);
});
});
it("tells React only when the snapshot really changed", async () => {
const { engine, asks } = setup();
const seen = vi.fn();
engine.subscribe(seen);
engine.start("A", 0);
const after = seen.mock.calls.length;
engine.start("A", 0); // same ready/error/transcode — nothing new to render
expect(seen.mock.calls.length).toBe(after);
await asks[0]!.resolve(direct("A"));
});
+546
View File
@@ -0,0 +1,546 @@
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;
private inFlight: Request | null = null;
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 && this.inFlight.id === id) this.queued = req;
else this.send(req);
}
private send(req: Request): void {
this.queued = null;
this.inFlight = 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 === req) this.inFlight = null;
const next = this.queued;
if (!next || this.inFlight) 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.
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 {
onManifestParsed: () => {
this.set({ ready: true });
if (this.alive && 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;
}
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;
}
}
+61
View File
@@ -0,0 +1,61 @@
import Hls from "hls.js";
import type { HlsHandlers, HlsPort } from "./hlsEngine";
// The one place hls.js is constructed for the Plex player: the engine (hlsEngine.ts) sees only the
// narrow `HlsPort`, so its tests run without hls.js or a media pipeline.
export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
if (!Hls.isSupported()) return null;
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts playback
// at the live edge and hugs it, so it never builds a forward buffer (rebuffer spinners), and on a
// resume/seek session the segments' absolute PTS (-copyts, offset by the server's -ss) don't line
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
// measurement (see plexStats.ts).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
}),
);
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
type: data.type,
details: data.details,
}),
);
return {
load(url, media) {
hls.loadSource(url);
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
getAudioTrack: () => hls.audioTrack,
setAudioTrack(track) {
hls.audioTrack = track;
},
levelInfo() {
// currentLevel is -1 while auto-ABR hasn't settled; loadLevel then gives the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
return {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
},
};
}
@@ -16,7 +16,6 @@ import { createRef, type ReactNode, type RefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog";
import type Hls from "hls.js";
const RK = "98614";
@@ -44,15 +43,13 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
sessionK,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
playhead: () => (abs > 0 ? abs : loadStart),
}),
{ wrapper, initialProps: { id: RK } },
);
@@ -320,15 +317,13 @@ function TrackHarness() {
sessionK: 0,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: 3600 },
loadStartRef: { current: 0 },
playhead: () => 3600,
});
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
harness.current = tracks;
+21 -39
View File
@@ -7,7 +7,6 @@ import {
type WheelEvent as ReactWheelEvent,
} from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
fullscreenElement,
@@ -343,15 +342,6 @@ export function usePlexProgress(opts: {
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that. */
export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
// default volume after a load/seek-restart). The 0..1 arithmetic is the shared, unit-tested core in
@@ -551,8 +541,8 @@ export function useAutoSkip(opts: {
}
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state and the
// audio/subtitle change/cycle handlers; audio switches re-map server-side via loadSession (single
// stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// audio/subtitle change/cycle handlers; audio switches re-map server-side via a session restart
// (single stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
// component and passed in so this hook and the media session read/write the same substrate.
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
@@ -589,17 +579,17 @@ export function usePlexTracks(opts: {
sessionK: number;
subOffset: number;
subPos: number;
loadSession: (startAt: number, resumePlay?: boolean) => Promise<void>;
/** Restart the item's session at `startAt` (HlsEngine.start). */
restartSession: (startAt: number) => void;
/** Switch audio client-side; false when no multi-rendition session is attached (HlsEngine). */
setAudioTrack: (track: number) => boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>;
audioRef: RefObject<number | null>;
aoffRef: RefObject<number>;
absRef: RefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: RefObject<number>;
/** Where the viewer is — before playback has ticked, where the session was ASKED to start
* (HlsEngine.playhead). Anything that restarts "here", and the subtitle poll, must use it. */
playhead: () => number;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -636,8 +626,9 @@ export function usePlexTracks(opts: {
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = opts;
const { id, detail, sessionK, subOffset, subPos, restartSession, setAudioTrack, patchPrefs } =
opts;
const { videoRef, audioRef, aoffRef, playhead } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
@@ -658,7 +649,7 @@ export function usePlexTracks(opts: {
}, [subOffset]);
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so loadSession sees it.
// position (like a seek). The ref is updated synchronously so the session request sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
@@ -668,20 +659,11 @@ export function usePlexTracks(opts: {
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
const track = ord ?? 0;
try {
hlsRef.current.audioTrack = track;
} catch {
/* ignore */
}
} else {
// Single audio (or the session has not answered yet): re-map the track server-side.
void loadSession(playhead(absRef, loadStartRef));
}
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
// Single audio (or the session has not answered yet): re-map the track server-side.
if (!setAudioTrack(ord ?? 0)) restartSession(playhead());
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
[restartSession, setAudioTrack, detail, patchPrefs, audioRef, playhead],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
@@ -730,9 +712,9 @@ export function usePlexTracks(opts: {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
aoffTimer.current = window.setTimeout(() => restartSession(playhead()), 500);
},
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
[patchPrefs, restartSession, aoffRef, playhead],
);
// --- is the selected track actually extractable yet? -------------------------------------------
@@ -751,7 +733,7 @@ export function usePlexTracks(opts: {
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead()),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
@@ -852,7 +834,7 @@ export function usePlexTracks(opts: {
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
// sessionK is already lag-compensated (see HlsEngine), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
+1 -1
View File
@@ -1,6 +1,6 @@
// Pure formatters + row builder for the Plex player "stats for nerds" overlay. Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// the session engine and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
// The numbers are in the UI language ("4,2 Mbps", "12,3 mp" in Hungarian); the unit symbols of a
// stream (Mbps, kbps) are the same in both.