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
@@ -502,11 +502,18 @@ describe("restarting the session before playback has ticked", () => {
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
const answers = [deferred(), deferred(), deferred()];
let calls = 0;
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
// One request per item in flight: the switch waits for the start's answer…
await act(async () => answers[0]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
fireEvent.keyDown(window, { key: "ArrowRight" });
await act(async () => answers[1]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
@@ -529,12 +536,41 @@ it("a seek made while a restart is pending wins over it", async () => {
await playingAt("/media/A", 95);
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
await tick();
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
// on localdev, 2026-10-06).
let calls = 0;
const restart = deferred();
mount({
details: { A: item("A", 1200) },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? restart.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
await playingAt("/media/A", 1200);
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
await tick();
expect(startsAsked("A")).toEqual([1200, 1190]);
await act(async () => restart.resolve(direct("A")));
// The queued target is the LAST press's, asked once.
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },
+147 -431
View File
@@ -1,4 +1,13 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -6,7 +15,6 @@ import { plexLang } from "../i18n";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useFocusKeeper } from "../lib/useFocusKeeper";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
ArrowLeft,
Clock,
@@ -27,7 +35,7 @@ import {
Volume2,
VolumeX,
} from "lucide-react";
import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
import { api, HttpError, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useLatest } from "../lib/useLatest";
import { seekableEndOf } from "../lib/seekableEnd";
@@ -36,7 +44,6 @@ import {
clockParts,
DEFAULT_PREFS,
fmt,
HLS_SUB_LAG,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
@@ -45,13 +52,14 @@ import { Ctrl, PanelHead } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
import { HlsEngine } from "../lib/hlsEngine";
import { createHlsPort } from "../lib/hlsPort";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import {
playhead,
useAutoSkip,
useControlsVisibility,
useFullscreen,
@@ -94,17 +102,6 @@ import { trackLabel } from "../lib/vocab";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// 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 the ERROR handler).
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 the ERROR handler
// 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.
const RECLAIM_WINDOW_MS = 120_000;
// C5: Back restarts the item after this many seconds in, and steps to the previous one before it.
const BACK_TO_PREV_S = 3;
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
@@ -134,11 +131,6 @@ type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?
export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) {
const { t, i18n } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useLatest(t);
// A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
@@ -154,86 +146,33 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const duration = detail?.duration_seconds ?? 0;
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
// can't start a detached <video> playing audio in the background.
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body>. The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount 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.
const sessGenRef = useRef(0);
// Removes the media listeners the current session put on the <video> (its canplay / loadedmetadata
// handlers). A newer session, an item change and the unmount all call it: the generation check
// 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 (whose first checkpoint
// then saved it), or start the old media playing under the new title.
const detachSessionRef = useRef<() => void>(() => {});
// Whether the <video>'s clock belongs to the session `sessionStartRef` describes. False from the
// moment a session is asked for until it has answered: the media still attached meanwhile is the
// OLD one, so `sessionStartRef + currentTime` was the previous item's position (go()'s pause() fires
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
const clockLiveRef = useRef(false);
// Whether the session being asked for plays once it can — null while none is out. Set from
// loadSession's `resumePlay` and then the viewer's to change: 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. `said`: the viewer changed it, so it is what they now see.
const pendingPlayRef = useRef<{ play: boolean; said?: boolean } | null>(null);
// The key of the last press that landed on faded bands, while it is held (see onKey).
const blindKeyRef = useRef<string | null>(null);
// The item whose session is attached to the <video> right now (see loadSession's failure path).
const attachedIdRef = useRef<string | null>(null);
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useLatest(duration);
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
const fragMeterRef = useRef(createFragMeter());
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
// client-side (no restart). Ref so the engine and changeAudio read it fresh.
const multiAudioRef = useLatest((detail?.audio_streams.length ?? 0) > 1);
const [playing, setPlaying] = useState(false);
const [abs, setAbs] = useState(0); // absolute current time
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
const absRef = useLatest(abs);
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
const loadStartRef = useRef(0);
// How often this player may reclaim a session the server answered 404 for, before it stops and
// says playback failed. `at` is when the window started; the item-start effect zeroes it, so a
// budget spent on one episode cannot deny the next one its first legitimate reclaim.
const restartsRef = useRef({ n: 0, at: 0 });
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
// True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives
// a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`,
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
// this and re-fetches when a seek-restart changes it.
const [sessionK, setSessionK] = useState(0);
// Per-account persisted player prefs — survive F5, carry across items.
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
@@ -241,17 +180,79 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
DEFAULT_PREFS,
);
const { volume, muted } = prefs;
const aoffRef = useLatest(prefs.audioOffset);
const audioRef = useRef<number | null>(null);
// The session machine (lib/hlsEngine.ts): which server session the <video> plays, what a seek or a
// track change asks for, and which answers still belong to anything. One per player instance,
// connected to the player's refs from a layout effect — before any effect or handler can use it.
const [engine] = useState(() => new HlsEngine());
useLayoutEffect(
() =>
engine.connect({
media: () => videoRef.current,
requestSession: (itemId, startAt, p) =>
api.plexSession(itemId, startAt, p.multi ? null : p.audio, p.aoff, p.multi),
stopSession: (itemId, sessionId) => api.plexStopSession(itemId, sessionId),
createHls: (handlers) => {
// A new session is a new stream (another offset, maybe another audio rendition): the
// bitrate window starts empty rather than carrying the previous one's numbers over.
fragMeterRef.current.reset();
return createHlsPort(handlers);
},
params: () => ({
multi: multiAudioRef.current,
audio: audioRef.current,
aoff: aoffRef.current,
}),
position: () => absRef.current,
duration: () => durationRef.current,
now: () => Date.now(),
statusOf: (e) => (e instanceof HttpError ? e.status : undefined),
onLoadStart: () => setBuffering(false),
onFragLoaded: (frag) => fragMeterRef.current.add(frag),
// Fatal ones are what the viewer saw fail; the recoverable ones hls.js retries by itself only
// matter to a detailed diagnosis.
onHlsError: (e) => {
if (!e.fatal && !enabled("DEBUG")) return;
log.at(e.fatal ? "WARNING" : "DEBUG", "player.hls_error", `plex hls ${e.details}`, {
player: "plex",
fatal: e.fatal,
type: e.type,
details: e.details,
status: e.status,
});
},
}),
[engine, multiAudioRef, aoffRef, absRef, durationRef],
);
// `ready`: the INITIAL load of the current session is done. `transcode`: it re-encodes the video, so
// its cold start is genuinely slower than a remux and the overlay says "Preparing stream…" instead of
// a generic "Loading…". `sessionK`: its real media start — subtitles are absolute-time VTT shifted by
// it, so the <track> re-fetches when a seek-restart changes it.
const {
ready,
error: engineError,
transcode: sessionIsTranscode,
sessionK,
} = useSyncExternalStore(engine.subscribe, engine.getSnapshot);
const loadError =
engineError === "transcode"
? t("plex.player.errTranscode")
: engineError === "notFound"
? t("plex.player.errNotFound")
: engineError
? t("plex.player.errGeneric")
: null;
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
const barAutoHideRef = useLatest(prefs.barAutoHide);
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
// a ref for the same reason `tRef` above does: naming them in that effect's deps would re-run it
// when the user picks a track from the player's own menu, double-loading the session the picker
// has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
// a ref: naming them in that effect's deps would re-run it when the user picks a track from the
// player's own menu, double-loading the session the picker has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
const langPrefsRef = useLatest({ audioLang: prefs.audioLang, subLang: prefs.subLang });
const seekStepRef = useLatest(prefs.seekStep);
const fineSeekStepRef = useLatest(prefs.fineSeekStep);
const aoffRef = useLatest(prefs.audioOffset);
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
const [moreOpen, setMoreOpen] = useState(false);
@@ -259,7 +260,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
);
const audioRef = useRef<number | null>(null);
// keep the control bar up while one is open
const menuOpenRef = useLatest(menuOpen || tracksOpen || moreOpen);
const menuRef = useRef<HTMLDivElement>(null);
@@ -367,250 +367,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Track selection is (re)derived from the saved language prefs when each item's detail arrives
// (see the detail effect below) — nothing to reset on id change.
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
// The self-restart path inside loadSession reaches the CURRENT loadSession through this, so the
// recursion is not a forward reference to a binding that is still being defined. Declared before
// it and set after commit (below) — a render-time write would make the React Compiler skip the
// whole player.
const loadSessionRef = useRef<(startAt: number, resumePlay?: boolean) => Promise<void>>(
async () => {},
// --- media setup: (re)start a session for `id` at a start offset -----------------------------
const restartSession = useCallback(
(startAt: number, play = true) => engine.start(id, startAt, play),
[engine, id],
);
const loadSession = useCallback(
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
detachSessionRef.current();
detachSessionRef.current = () => {};
const listen = (type: string, fn: () => void, once = false) => {
const guarded = () => {
if (current()) fn();
};
video.addEventListener(type, guarded, { once });
const prev = detachSessionRef.current;
detachSessionRef.current = () => {
prev();
video.removeEventListener(type, guarded);
};
};
clockLiveRef.current = false;
const intent: { play: boolean; said?: boolean } = { play: resumePlay };
pendingPlayRef.current = intent;
// Answered (or failed): the media from here on is this session's, and a Space is its own.
const settled = () => {
if (pendingPlayRef.current === intent) pendingPlayRef.current = null;
};
loadStartRef.current = startAt;
setReady(false);
setBuffering(false);
setLoadError(null);
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
// Initialised, not a bare `let sess;`: the compiler's analysis read an unassigned local as a
// value from OUTSIDE this callback and asked for `sess.hw` as a dependency (memo-dependencies).
let sess: PlexPlaySession | null = null;
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
// selected client-side after the manifest loads. Decided before the try: the React Compiler
// cannot lower a conditional inside one yet.
const m = multiAudioRef.current;
const audio = m ? null : audioRef.current;
try {
sess = await api.plexSession(id, startAt, audio, aoffRef.current, m);
} catch (e) {
if (!current()) return; // superseded or left: not this request's error to show
settled();
// 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.
clockLiveRef.current = attachedIdRef.current === id;
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError(
status === 501
? tRef.current("plex.player.errTranscode")
: status === 404
? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"),
);
return;
}
if (!current()) return;
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s 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 — no compensation.
const trueStart =
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart;
setSessionK(trueStart);
// From here the <video>'s clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
clockLiveRef.current = true;
attachedIdRef.current = id;
settled();
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
// the video paused after a seek even though the user was watching. Retry once the element can
// actually play. Idempotent — only acts if still paused. The intent is read when it fires: the
// viewer may have asked for play or pause since the request went out.
listen(
"canplay",
() => {
if (intent.play && videoRef.current?.paused) videoRef.current.play().catch(() => {});
},
true,
);
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
// 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 → permanent
// stall. But the seek-restart model means fragment 0 IS the requested content position
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls;
// Measure the real delivered bitrate: bytes per second of MEDIA, per fragment. A new session
// is a new stream (different start offset, possibly a different audio rendition), so the
// rolling window starts empty rather than carrying the previous session's numbers over.
fragMeterRef.current.reset();
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
fragMeterRef.current.add({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
});
});
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
// Whether this session has ever played. A reclaim of one that has not (below) keeps the
// start's own `resumePlay`: its <video> is still paused, and that is not the viewer's pause.
let parsed = false;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
parsed = true;
setReady(true);
if (aliveRef.current && intent.play) video.play().catch(() => {});
});
// Restore the selected audio track ONCE hls.js has parsed the renditions — doing it on
// MANIFEST_PARSED was too early (the track list wasn't populated, so the set was dropped and
// playback stayed on the default track while the UI showed the restored selection).
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
if (multiAudioRef.current) {
const want = audioRef.current ?? 0;
if (hls.audioTrack !== want) {
try {
hls.audioTrack = want;
} catch {
/* out of range — ignore */
}
}
}
});
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it 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, and a new session at the playhead is what
// closing and reopening the player did by hand — or it was superseded before it ever played:
// two requests for one item in flight at once (a seek twice in quick succession; StrictMode
// replaying the start effect in development) and the server's LAST spawn wins, which may be
// the older request's. The newer request's answer then names a session that is already
// gone, and its playlist 404s before a manifest parsed — "couldn't start" on a file that
// plays (Arrow S03E14, 2026-09-27). The server answers a superseded session exactly so the
// client asks again; the reclaim is that asking.
//
// Bounded by the budget below, not by whether the session parsed: `parsed` belongs to ONE hls
// instance 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 from "the
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
// that never became an error. A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
// only matter to a detailed diagnosis.
if (data.fatal || enabled("DEBUG")) {
log.at(
data.fatal ? "WARNING" : "DEBUG",
"player.hls_error",
`plex hls ${data.details}`,
{
player: "plex",
fatal: data.fatal,
type: data.type,
details: data.details,
status: data.response?.code ?? null,
},
);
}
if (!data.fatal || !current()) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
// A new window: the last loss was long enough ago that this is a fresh incident, not the
// same session being lost over and over. The anchor moves with EVERY loss, because with
// it left at the window's start the window tumbled instead of rolling: any loss period
// longer than half the window satisfied the reset on every second loss, so the budget
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
// is why the committed test, spacing its cases 0s and 10min apart, could not see it).
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
restartsRef.current.at = now;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
// Through the ref, not by name: this is loadSession calling ITSELF from inside its own
// useCallback, which the checker rightly reads as "accessed before it is declared".
// The ref (declared above the callback, set below it) is the file's answer to that —
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
void loadSessionRef.current(
playhead(absRef, loadStartRef),
parsed ? !video.paused : intent.play,
);
return;
}
}
setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
listen(
"loadedmetadata",
() => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (intent.play) video.play().catch(() => {});
},
true,
);
}
},
// NOT `t` — see tRef above (a language switch must not rebuild this / reload the session). The
// refs never change.
[id, tRef, multiAudioRef, aoffRef, absRef],
);
useLayoutEffect(() => {
loadSessionRef.current = loadSession;
}, [loadSession]);
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
// media refs stay component-owned (the detail effect below writes audioRef too).
const {
audioOrd,
setAudioOrd,
@@ -635,15 +399,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
loadSession,
restartSession,
setAudioTrack: (track: number) => engine.setAudioTrack(track),
patchPrefs,
videoRef,
hlsRef,
multiAudioRef,
audioRef,
aoffRef,
absRef,
loadStartRef,
playhead: () => engine.playhead(),
});
// Start playback when the detail arrives (resume from the saved position unless already watched) —
@@ -665,11 +427,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
useEffect(() => {
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
// 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 — `go()` only changes `id`).
restartsRef.current = { n: 0, at: 0 };
engine.newItem();
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
// audio ref synchronously so the session request below picks the right track on the first ask.
const { audioLang, subLang } = langPrefsRef.current;
const aOrd = audioOrdForLang(detail.audio_streams ?? [], audioLang);
audioRef.current = aOrd;
@@ -681,67 +441,60 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
startAtZeroRef.current !== id
? detail.position_seconds || 0
: 0;
void loadSession(resume);
}, [detail, id, fromStart, loadSession, audioRef, langPrefsRef, setAudioOrd, setSubOrd]);
restartSession(resume);
}, [
detail,
id,
fromStart,
engine,
restartSession,
audioRef,
langPrefsRef,
setAudioOrd,
setSubOrd,
]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
sessGenRef.current++;
detachSessionRef.current();
detachSessionRef.current = () => {};
engine.leaveItem();
startedForRef.current = null;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
},
[id],
[engine, id],
);
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
// play() can't leave audio playing on a detached element after leaving the player.
// Full media teardown on unmount (Back): the engine stops and detaches the <video> and its stream,
// so a late play() can't leave audio playing on a detached element after leaving the player.
//
// The setup half matters as much as the cleanup: React may run the cleanup and then MOUNT AGAIN
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
// for the life of the player — every play() is gated on it, so the player never started by itself,
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
// (StrictMode does exactly that in development). Without `revive` the engine stayed closed for the
// life of the player — every play() is gated on it, so the player never started by itself, and a
// seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
//
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach below
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
useEffect(() => {
aliveRef.current = true;
engine.revive();
const v = videoRef.current;
// An unload runs no effect cleanup: the server session is stopped here instead, unless the page
// goes into the back/forward cache and may come back to it.
const onHide = (e: PageTransitionEvent) => engine.pageHidden(e.persisted);
window.addEventListener("pagehide", onHide);
return () => {
// Lowering the flag is what tells every in-flight session that its answer belongs to nothing
// now (`current()` reads it). The generation is bumped by the "leaving the item" cleanup
// above, which an unmount runs too.
aliveRef.current = false;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (v) {
try {
v.pause();
v.removeAttribute("src");
v.load();
} catch {
/* ignore */
}
}
window.removeEventListener("pagehide", onHide);
engine.destroy(v);
};
}, []);
}, [engine]);
// --- time tracking + progress checkpoints ----------------------------------------------------
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => {
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
const a = sessionStartRef.current + video.currentTime;
const a = engine.absTime(video);
if (a == null) return; // the old session's clock (see HlsEngine's `live`)
setAbs(a);
const end = seekableEndOf(video, sessionStartRef.current, a);
const end = seekableEndOf(video, engine.sessionStart.current, a);
if (end != null) setSeekableEnd(end);
};
const onPlay = () => setPlaying(true);
@@ -786,7 +539,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
video.removeEventListener("seeked", onResumed);
video.removeEventListener("pause", onResumed);
};
}, [id]);
}, [id, engine]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
@@ -800,61 +553,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
(target: number, play?: boolean) => {
const video = videoRef.current;
if (!video) return;
// Play/pause intent across the seek: the caller's when it has one (a Restart plays), else
// whatever the video was doing.
const wasPlaying = play ?? !video.paused;
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
const T = engine.clamp(target);
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
// too: `absRef` is re-synced from `abs` after every commit, and the restart below renders at once,
// so the ref alone was back at the pre-seek spot before anything could read it.
// too: `absRef` is re-synced from `abs` after every commit, and a restart renders at once, so
// the ref alone was back at the pre-seek spot before anything could read it.
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStartRef` and `seekable` describe the OLD one: a native seek there left the
// pending restart current, and its answer then jumped to the earlier target.
if (
clockLiveRef.current &&
rel >= 0 &&
rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5
) {
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
if (play) video.play().catch(() => {});
} else {
void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
}
engine.seek(id, T, play);
},
[loadSession, absRef, durationRef],
[engine, id, absRef],
);
// --- controls --------------------------------------------------------------------------------
// While a session is being asked for, play and pause are the pending session's intent (see
// pendingPlayRef): the outgoing media is never played, only paused if the viewer asks for a pause.
const requestPlay = useCallback((want: boolean) => {
const v = videoRef.current;
if (!v) return;
const pending = pendingPlayRef.current;
if (pending) {
pending.play = want;
pending.said = true;
if (!want && !v.paused) v.pause();
setPlaying(want);
return;
}
if (want) v.play().catch(() => {});
else v.pause();
}, []);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
// What the viewer sees: the outgoing media's state, until they have said otherwise.
const pending = pendingPlayRef.current;
const shown = pending?.said ? pending.play : !v.paused;
requestPlay(!shown);
}, [requestPlay]);
// HlsEngine.requestPlay): the media has not changed, so the button shows the intent itself.
const requestPlay = useCallback(
(want: boolean) => {
if (engine.requestPlay(want)) setPlaying(want);
},
[engine],
);
const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
@@ -868,8 +587,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
});
const reportWatchedError = useCallback(
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
[tRef],
(e: unknown) => notifyActionError(e, t("plex.watchedFailed")),
[t],
);
const go = useCallback(
@@ -892,7 +611,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// handed over so a REFUSED one takes the finish back instead of leaving the cache lying.
if (watchedWrite) finishProgress(watchedWrite);
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// file), the failed session request leaves the <video> source in place, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
const outgoing = videoRef.current;
@@ -907,13 +626,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
// reset below and would otherwise hand the old position to the new item.
settleProgress();
clockLiveRef.current = false;
engine.releaseClock();
movedOnRef.current = true;
setAbs(0);
setId(otherId);
}
},
[id, seekTo, requestPlay, settleProgress, finishProgress],
[id, engine, seekTo, requestPlay, settleProgress, finishProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
@@ -936,9 +655,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
const backToPrev = abs <= BACK_TO_PREV_S && !!prevId;
const backOrPrev = useCallback(() => {
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
if (engine.playhead() <= BACK_TO_PREV_S && prevId) go(prevId);
else seekTo(0);
}, [prevId, go, seekTo, absRef]);
}, [prevId, go, seekTo, engine]);
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
@@ -973,7 +692,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
if (prev.skips === 0) showControls(prev.wasShown);
const skips = prev.skips + 1;
tapRef.current = { ...prev, at: now, skips };
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
seekTo(engine.playhead() + zone * TAP_SKIP_S);
flashOsd(
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
d: formatUnit(skips * TAP_SKIP_S, "second", lang),
@@ -989,7 +708,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
}
toggleControls();
},
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible, absRef],
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible, engine],
);
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
@@ -1160,7 +879,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
seekTo(playhead(absRef, loadStartRef) + dir * step);
seekTo(engine.playhead() + dir * step);
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
@@ -1318,7 +1037,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
handleBack,
skipProgressRef,
cancelAutoSkipRef,
absRef,
engine,
seekStepRef,
fineSeekStepRef,
menuOpenRef,
@@ -1904,7 +1623,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{statsOpen && (
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
engine={engine}
fragMeterRef={fragMeterRef}
audioLabel={currentAudio ? audioName(currentAudio) : undefined}
subLabel={
@@ -1915,9 +1634,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
: undefined
}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
onClose={() => setStatsOpen(false)}
+11 -44
View File
@@ -1,8 +1,8 @@
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
import type { HlsEngine } from "../lib/hlsEngine";
import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats";
import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay. Samples the live HTML5 <video> + hls.js state once a second
@@ -23,29 +23,24 @@ function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: numb
export function PlexStatsOverlay({
videoRef,
hlsRef,
engine,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
// The session machine: the hls.js level, the transcode encoder and the session's start offset.
engine: Pick<HlsEngine, "levelInfo" | "hw" | "tonemap" | "sessionStart">;
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
// long as this panel) — the source of the MEASURED bitrate row.
fragMeterRef: RefObject<FragMeter>;
audioLabel?: string; // selected audio track label, as the tracks menu names it
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
@@ -57,7 +52,6 @@ export function PlexStatsOverlay({
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const hls = hlsRef.current;
const { dropped, total } = frameCounts(v);
@@ -72,29 +66,15 @@ export function PlexStatsOverlay({
}
}
let level: HlsLevelInfo | null = null;
if (hls) {
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
level = {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
}
const level = engine.levelInfo();
setRows(
buildStatRows({
lang,
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
hwEncoder: engine.hw,
tonemap: engine.tonemap,
transport: level ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
@@ -104,7 +84,7 @@ export function PlexStatsOverlay({
measuredBps: fragMeterRef.current?.bitrate(),
audioLabel,
subLabel,
sessionStartS: sessionStartRef.current ?? 0,
sessionStartS: engine.sessionStart.current,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
@@ -113,20 +93,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [
lang,
videoRef,
hlsRef,
fragMeterRef,
audioLabel,
subLabel,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
]);
}, [lang, videoRef, engine, fragMeterRef, audioLabel, subLabel, playable, absRef, durationRef]);
return (
<div