refactor(ui): every function compiles under the React Compiler but VirtualGrid, and react-hooks/refs is on
E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
import { seekableEndOf } from "../lib/seekableEnd";
|
||||
import {
|
||||
audioOrdForLang,
|
||||
clockParts,
|
||||
@@ -67,7 +69,6 @@ import {
|
||||
useHelpConditions,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyScope,
|
||||
usePaletteOpen,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
@@ -137,8 +138,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// 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 = useRef(t);
|
||||
tRef.current = t;
|
||||
const tRef = useLatest(t);
|
||||
// E30: 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);
|
||||
@@ -197,21 +197,19 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// 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 = useRef(0);
|
||||
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.
|
||||
const multiAudioRef = useRef(false);
|
||||
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
|
||||
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 = useRef(0);
|
||||
absRef.current = abs;
|
||||
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);
|
||||
@@ -244,21 +242,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
const { volume, muted } = prefs;
|
||||
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||||
const barAutoHideRef = useRef(prefs.barAutoHide);
|
||||
barAutoHideRef.current = prefs.barAutoHide;
|
||||
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
|
||||
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
|
||||
const langPrefsRef = useRef({ audioLang: prefs.audioLang, subLang: prefs.subLang });
|
||||
langPrefsRef.current = { audioLang: prefs.audioLang, subLang: prefs.subLang };
|
||||
const seekStepRef = useRef(prefs.seekStep);
|
||||
seekStepRef.current = prefs.seekStep;
|
||||
const fineSeekStepRef = useRef(prefs.fineSeekStep);
|
||||
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||
const aoffRef = useRef(prefs.audioOffset);
|
||||
aoffRef.current = prefs.audioOffset;
|
||||
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);
|
||||
@@ -267,10 +260,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
"sync",
|
||||
);
|
||||
const audioRef = useRef<number | null>(null);
|
||||
const menuOpenRef = useRef(false);
|
||||
// keep the control bar up while one is open
|
||||
const menuOpenRef = useLatest(menuOpen || tracksOpen || moreOpen);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||
const {
|
||||
uiVisible,
|
||||
@@ -325,6 +318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
fitHeight: true,
|
||||
openOnArrowKey: false,
|
||||
});
|
||||
const { setReference: setTracksReference } = tracksPop.refs;
|
||||
const { ref: tracksFadeRef, style: tracksFadeStyle } = useScrollFade();
|
||||
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||||
const setMoreReference = morePop.refs.setReference;
|
||||
@@ -339,11 +333,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
// know an overlay is up before it steals a wheel scroll for volume.
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
|
||||
const paletteOpenRef = useRef(false);
|
||||
paletteOpenRef.current = usePaletteOpen();
|
||||
const helpOpenRef = useLatest(helpOpen);
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
@@ -364,26 +354,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
infoOpenRef.current = infoOpen;
|
||||
const infoOpenRef = useLatest(infoOpen);
|
||||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useRef(false);
|
||||
statsOpenRef.current = statsOpen;
|
||||
const statsOpenRef = useLatest(statsOpen);
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useRef(false);
|
||||
upNextOpenRef.current = upNextOpen;
|
||||
const upNextPrefRef = useRef(prefs.upNext);
|
||||
upNextPrefRef.current = prefs.upNext;
|
||||
const upNextOpenRef = useLatest(upNextOpen);
|
||||
const upNextPrefRef = useLatest(prefs.upNext);
|
||||
const now = useWallClock();
|
||||
|
||||
durationRef.current = duration;
|
||||
|
||||
// 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 () => {},
|
||||
);
|
||||
const loadSession = useCallback(
|
||||
async (startAt: number, resumePlay = true) => {
|
||||
const video = videoRef.current;
|
||||
@@ -421,11 +412,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = 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 {
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
// selected client-side after the manifest loads.
|
||||
const m = multiAudioRef.current;
|
||||
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
|
||||
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();
|
||||
@@ -583,7 +576,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
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 (assigned right below the callback) is the file's existing answer to that —
|
||||
// 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),
|
||||
@@ -609,13 +602,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
}
|
||||
},
|
||||
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
||||
// NOT `t` — see tRef above (a language switch must not rebuild this / reload the session). The
|
||||
// refs never change.
|
||||
[id, tRef, multiAudioRef, aoffRef, absRef],
|
||||
);
|
||||
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
|
||||
// not a forward reference to a binding that is still being defined.
|
||||
const loadSessionRef = useRef(loadSession);
|
||||
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
|
||||
loadSessionRef.current = loadSession;
|
||||
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).
|
||||
@@ -690,7 +683,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
? detail.position_seconds || 0
|
||||
: 0;
|
||||
void loadSession(resume);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, langPrefsRef, setAudioOrd, setSubOrd]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -721,13 +714,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
aliveRef.current = true;
|
||||
const v = videoRef.current;
|
||||
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;
|
||||
// The rule's advice — copy the ref into a variable and use the copy — is precisely wrong here.
|
||||
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
|
||||
// that its response no longer belongs to anything, and a value captured when the effect ran
|
||||
// would invalidate a generation that has since been superseded instead of the current one.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sessGenRef is a LIVE counter: unmount must bump the current generation, not a copy
|
||||
sessGenRef.current++;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
@@ -752,12 +742,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
|
||||
const a = sessionStartRef.current + video.currentTime;
|
||||
setAbs(a);
|
||||
try {
|
||||
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
|
||||
setSeekableEnd(end);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const end = seekableEndOf(video, sessionStartRef.current, a);
|
||||
if (end != null) setSeekableEnd(end);
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
@@ -823,7 +809,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const dur = durationRef.current || target;
|
||||
const T = Math.max(0, Math.min(target, dur - 1));
|
||||
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
||||
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
|
||||
// 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.
|
||||
absRef.current = T;
|
||||
setAbs(T);
|
||||
@@ -842,7 +828,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
|
||||
}
|
||||
},
|
||||
[loadSession],
|
||||
[loadSession, absRef, durationRef],
|
||||
);
|
||||
|
||||
// --- controls --------------------------------------------------------------------------------
|
||||
@@ -884,7 +870,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
|
||||
const reportWatchedError = useCallback(
|
||||
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
|
||||
[],
|
||||
[tRef],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
@@ -910,10 +896,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// file), loadSession returns without replacing the <video> source, 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.
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
const outgoing = videoRef.current;
|
||||
if (outgoing) {
|
||||
try {
|
||||
outgoing.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Settle the item being left while the position still is its own (see usePlexProgress), and
|
||||
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
|
||||
@@ -950,7 +939,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const backOrPrev = useCallback(() => {
|
||||
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
|
||||
else seekTo(0);
|
||||
}, [prevId, go, seekTo]);
|
||||
}, [prevId, go, seekTo, absRef]);
|
||||
|
||||
// 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.
|
||||
@@ -1001,7 +990,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
}
|
||||
toggleControls();
|
||||
},
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible],
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible, absRef],
|
||||
);
|
||||
|
||||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||||
@@ -1103,7 +1092,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
};
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError]);
|
||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
@@ -1134,7 +1123,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
}, [onClose, menuOpenRef, infoOpenRef, statsOpenRef, upNextOpenRef]);
|
||||
|
||||
// The mouse "back" button (and the browser Back) fire history popstate — which would pop the whole
|
||||
// player subview and drop straight to the grid, even with a panel or an auto-skip countdown up.
|
||||
@@ -1330,6 +1319,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
handleBack,
|
||||
skipProgressRef,
|
||||
cancelAutoSkipRef,
|
||||
absRef,
|
||||
seekStepRef,
|
||||
fineSeekStepRef,
|
||||
menuOpenRef,
|
||||
infoOpenRef,
|
||||
statsOpenRef,
|
||||
upNextOpenRef,
|
||||
]);
|
||||
|
||||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
||||
@@ -1768,7 +1764,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={tracksPop.refs.setReference}
|
||||
ref={setTracksReference}
|
||||
{...tracksPop.getReferenceProps()}
|
||||
aria-expanded={tracksOpen}
|
||||
label={t("plex.player.tracks")}
|
||||
|
||||
Reference in New Issue
Block a user