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:
@@ -1117,6 +1117,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
||||
return dropped
|
||||
|
||||
|
||||
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
||||
"""Stop this viewer's session for `key` now, because the player that asked for it is gone (left
|
||||
the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the
|
||||
film first — measured: ~4 cores for a minute after leaving a resumed 480p film.
|
||||
|
||||
Only while `session_id` is still the registered one: the player names the session it played, and
|
||||
a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's
|
||||
current playback, not this request's to end."""
|
||||
with _lock:
|
||||
s = _sessions.get((user_id, key))
|
||||
if s is None or s.id != session_id:
|
||||
return False
|
||||
_kill(s)
|
||||
_sessions.pop((user_id, key), None)
|
||||
return True
|
||||
|
||||
|
||||
def touch_session(user_id: int, key: str) -> None:
|
||||
"""Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||
|
||||
@@ -637,12 +637,27 @@ def stream_session(
|
||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||
# names resolve back into the same session — see `stream_hls_file` for why it has to.
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
|
||||
# What the player names when it no longer wants the session (POST …/session/{id}/stop).
|
||||
"id": s.id,
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
"tonemap": s.tonemap,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session/{session_id}/stop")
|
||||
def stream_session_stop(
|
||||
rating_key: str,
|
||||
session_id: str,
|
||||
user: User = Depends(current_user),
|
||||
) -> dict:
|
||||
"""The player no longer wants this session (it left the item, closed, or the page unloaded):
|
||||
its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a
|
||||
keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and
|
||||
only while it is still the one registered for the item (see `stop_session`)."""
|
||||
return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/file")
|
||||
def stream_file(
|
||||
rating_key: str,
|
||||
|
||||
@@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
|
||||
assert body["mode"] == "hls"
|
||||
assert body["transcode"] is False
|
||||
assert captured["transcode"] is False
|
||||
# The id the player names when it stops the session (POST …/session/{id}/stop).
|
||||
assert body["id"] == "sid"
|
||||
|
||||
|
||||
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
|
||||
|
||||
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
|
||||
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
|
||||
played; the server stops it only while it is still that viewer's registered session for the item, so
|
||||
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
|
||||
|
||||
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
VIEWER = 41
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self):
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory):
|
||||
self.id = session_id
|
||||
self.dir = directory
|
||||
self.proc = _Proc()
|
||||
self.last_access = time.time()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(plex_stream, "_sessions", live)
|
||||
return live
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, override_dep):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
user = User(id=VIEWER, email="stop@example.com")
|
||||
override_dep(current_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _session(tmp_path, sid: str) -> _Session:
|
||||
d = tmp_path / sid
|
||||
d.mkdir()
|
||||
return _Session(sid, d)
|
||||
|
||||
|
||||
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
|
||||
s = _session(tmp_path, "sid-a")
|
||||
sessions[(VIEWER, "m1")] = s
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": True}
|
||||
assert s.proc.terminated
|
||||
assert sessions == {}
|
||||
assert not s.dir.exists()
|
||||
|
||||
|
||||
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
|
||||
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
|
||||
newer = _session(tmp_path, "sid-b")
|
||||
sessions[(VIEWER, "m1")] = newer
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not newer.proc.terminated
|
||||
assert sessions == {(VIEWER, "m1"): newer}
|
||||
|
||||
|
||||
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
|
||||
theirs = _session(tmp_path, "sid-a")
|
||||
sessions[(7, "m1")] = theirs
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
assert not theirs.proc.terminated
|
||||
|
||||
|
||||
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
|
||||
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
|
||||
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "stopped": False}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
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,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.
|
||||
|
||||
Reference in New Issue
Block a user