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:
@@ -315,6 +315,21 @@ export function useYtBigView({
|
||||
};
|
||||
}
|
||||
|
||||
/** The player's position and length, or null when there is no usable player — not built yet, or
|
||||
* tearing down (its methods throw then). Outside the hook: the React Compiler cannot lower a
|
||||
* conditional inside a try yet, and skipped useYtProgress for it. */
|
||||
function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
|
||||
if (!p || typeof p.getCurrentTime !== "function") return null;
|
||||
try {
|
||||
return {
|
||||
cur: p.getCurrentTime(),
|
||||
dur: typeof p.getDuration === "function" ? p.getDuration() : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
|
||||
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
|
||||
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
|
||||
@@ -358,10 +373,34 @@ export function useYtProgress({
|
||||
}): void {
|
||||
const autoMarkedRef = useRef(false);
|
||||
const errorStreakRef = useRef(0);
|
||||
// The render values this player is built from, read ONCE when it is built (see the dep note at
|
||||
// the end of the effect).
|
||||
const itemSnapshot = useEffectEvent(() => ({
|
||||
resumeAt,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = activeId;
|
||||
const {
|
||||
resumeAt,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
} = itemSnapshot();
|
||||
// A position this session already stored for the item beats the queue's snapshot of it.
|
||||
const startAt = sessionPosRef.current.get(id) ?? resumeAt;
|
||||
autoMarkedRef.current = false;
|
||||
setPlayerError(null);
|
||||
|
||||
@@ -379,28 +418,23 @@ export function useYtProgress({
|
||||
return false;
|
||||
};
|
||||
const persist = (): Promise<unknown> | void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||
// position, racing the watched write against the progress row it deletes).
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||
// user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return (
|
||||
api
|
||||
.saveProgress(id, cur, dur)
|
||||
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
|
||||
// cleared to 0 there, and the resume must agree with the row, not with our reading.
|
||||
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* player may be tearing down */
|
||||
const times = playerTimes(playerRef.current);
|
||||
if (!times) return;
|
||||
const { cur, dur } = times;
|
||||
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||
// position, racing the watched write against the progress row it deletes).
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||
// user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return (
|
||||
api
|
||||
.saveProgress(id, cur, dur)
|
||||
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
|
||||
// cleared to 0 there, and the resume must agree with the row, not with our reading.
|
||||
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -413,7 +447,7 @@ export function useYtProgress({
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
start: startAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
@@ -480,15 +514,9 @@ export function useYtProgress({
|
||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
|
||||
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
|
||||
const onHide = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||||
} catch {
|
||||
/* player tearing down */
|
||||
}
|
||||
if (currentIdRef.current !== id) return;
|
||||
const times = playerTimes(playerRef.current);
|
||||
if (times && times.cur > 0 && times.dur > 0) api.saveProgressBeacon(id, times.cur, times.dur);
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
|
||||
@@ -517,21 +545,18 @@ export function useYtProgress({
|
||||
}
|
||||
playerRef.current = null;
|
||||
};
|
||||
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
|
||||
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
|
||||
// every listener attached to it.
|
||||
// ONE PLAYER PER VIDEO is the invariant: re-running would destroy and rebuild the YouTube
|
||||
// iframe, losing the playing position and every listener attached to it. So the deps are the id
|
||||
// and refs (which never change).
|
||||
//
|
||||
// This body DOES capture render values — `resumeAt`, `setWatched`, `advanceOnEnd`,
|
||||
// `reapplyStored`, `focusModal`, `qc` — and it must, because each of them is a PER-ITEM
|
||||
// SNAPSHOT: they describe the video this player was built for, and re-reading them mid-session
|
||||
// would be the bug, not the fix. `resumeAt` is the clearest case, since persist() writes
|
||||
// sessionPosRef and so changes it while `activeId` has not moved; the player is already playing
|
||||
// from it and must not be told again. What genuinely has to stay live mid-session is behind refs
|
||||
// (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and
|
||||
// THAT split is the enforcement: a value that is not a snapshot does not belong in this closure.
|
||||
//
|
||||
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
|
||||
// object literal, and an effect event may not be passed around as a value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ONE player per video: the captured values are per-item snapshots, live ones are refs
|
||||
}, [activeId]);
|
||||
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
|
||||
// `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
|
||||
// player is built: they describe the video it was built for, and re-reading them mid-session
|
||||
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
|
||||
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
|
||||
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
|
||||
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
|
||||
// a value that is not a snapshot does not belong in this closure. (E32: this replaced an
|
||||
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
|
||||
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user