The production pass (7ed2ac4e, E32) asked "is this export used by what
ships?". No real hit: its only findings were 37 exports a test imports,
answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not
defects. Since then it has only ever asked for another tag. The tags go
with it (36, no longer read by anything), and so do the production-only
`!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused
files, exports and dependencies) keeps running in the gate.
562 lines
25 KiB
TypeScript
562 lines
25 KiB
TypeScript
import {
|
||
useEffect,
|
||
useEffectEvent,
|
||
useRef,
|
||
useState,
|
||
type Dispatch,
|
||
type RefObject,
|
||
type SetStateAction,
|
||
} from "react";
|
||
import type { QueryClient } from "@tanstack/react-query";
|
||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
|
||
import { LS, readAccountMerged, writeAccount } from "./storage";
|
||
import {
|
||
exitFullscreen,
|
||
fullscreenElement,
|
||
onFullscreenChange,
|
||
requestFullscreen,
|
||
} from "./fullscreen";
|
||
import { useBackToExit } from "./history";
|
||
import { api, type Video } from "./api";
|
||
import { qk } from "./queryKeys";
|
||
import { patchCachedProgress } from "./progressCache";
|
||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
|
||
import type { AutoMode, LoopMode } from "./playerModes";
|
||
|
||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||
const FINISH_MARGIN = 10;
|
||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run
|
||
// of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||
|
||
// --- IFrame Player API loader (singleton) ---
|
||
/** `playerError` when the IFrame API itself never arrived — not one of YouTube's own error codes
|
||
* (2/5/100/101/150, all positive), and distinct from the -1 an unparseable one maps to. */
|
||
export const YT_API_LOAD_FAILED = -2;
|
||
/** How long the script may take before the player gives up on it. The real answer comes in well
|
||
* under a second; anything near this is a blocked or stalled request, not a slow one. */
|
||
export const YT_API_TIMEOUT_MS = 15_000;
|
||
let apiPromise: Promise<YTNamespace> | null = null;
|
||
function loadYouTubeApi(): Promise<YTNamespace> {
|
||
if (apiPromise) return apiPromise;
|
||
apiPromise = new Promise<YTNamespace>((resolve, reject) => {
|
||
const w = window;
|
||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||
const tag = document.createElement("script");
|
||
// A failure is not memoised: the next open loads the script again instead of waiting on a
|
||
// promise that already lost (a blocker switched off, the network back).
|
||
const fail = (why: string) => {
|
||
window.clearTimeout(timer);
|
||
apiPromise = null;
|
||
tag.remove();
|
||
reject(new Error(`YouTube IFrame API ${why}`));
|
||
};
|
||
const timer = window.setTimeout(() => fail("timed out"), YT_API_TIMEOUT_MS);
|
||
const prev = w.onYouTubeIframeAPIReady;
|
||
w.onYouTubeIframeAPIReady = () => {
|
||
window.clearTimeout(timer);
|
||
if (typeof prev === "function") prev();
|
||
// The API-ready callback only fires once the script has installed window.YT.
|
||
resolve(w.YT!);
|
||
};
|
||
tag.onerror = () => fail("failed to load");
|
||
tag.src = "https://www.youtube.com/iframe_api";
|
||
document.head.appendChild(tag);
|
||
});
|
||
return apiPromise;
|
||
}
|
||
|
||
// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The
|
||
// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts
|
||
// object; each hook owns its own private state/timers and returns handlers + reactive values. The
|
||
// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate
|
||
// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks.
|
||
|
||
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
|
||
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
|
||
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
||
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area —
|
||
* except over a `[data-wheel-scrolls]` panel that can still scroll that way. */
|
||
export function useYtVolume({
|
||
playerRef,
|
||
dialogRef,
|
||
focusModal,
|
||
}: {
|
||
playerRef: RefObject<YTPlayer | null>;
|
||
dialogRef: RefObject<HTMLDivElement | null>;
|
||
focusModal: () => void;
|
||
}): {
|
||
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
||
volumeUi: number | null;
|
||
/** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */
|
||
reapplyStored: () => void;
|
||
/** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */
|
||
nudgeVolume: (delta: number) => void;
|
||
} {
|
||
const volTimerRef = useRef<number | undefined>(undefined);
|
||
// Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null`
|
||
// / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount.
|
||
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
||
const [storedVolume] = useState(() =>
|
||
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
||
);
|
||
const volumeRef = useRef(storedVolume);
|
||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||
const lastNudgeAtRef = useRef(0);
|
||
|
||
const flashVolume = (level: number) => {
|
||
setVolumeUi(level);
|
||
window.clearTimeout(volTimerRef.current);
|
||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||
};
|
||
const persistVolume = () => {
|
||
window.clearTimeout(volSaveTimerRef.current);
|
||
volSaveTimerRef.current = undefined;
|
||
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
|
||
};
|
||
const applyVolume = (level: number, flash = true) => {
|
||
// Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and
|
||
// jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
||
const next = normalizeVolume(level, volumeRef.current);
|
||
const p = playerRef.current;
|
||
if (p && typeof p.setVolume === "function") {
|
||
if (next > 0) p.unMute?.();
|
||
p.setVolume(next);
|
||
if (next === 0) p.mute?.();
|
||
}
|
||
volumeRef.current = next;
|
||
// Debounced: one write when the spin settles; the unmount cleanup flushes a pending write.
|
||
window.clearTimeout(volSaveTimerRef.current);
|
||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||
if (flash) flashVolume(next);
|
||
};
|
||
const nudgeVolume = (delta: number) => {
|
||
// No early return when the player isn't ready: the level lives in volumeRef and onReady applies it,
|
||
// so a wheel spin in the first second after opening still lands.
|
||
const p = playerRef.current;
|
||
// Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may
|
||
// have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so
|
||
// re-reading it mid-spin resurrects a stale level and silently drops notches.
|
||
const now = Date.now();
|
||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||
lastNudgeAtRef.current = now;
|
||
const live =
|
||
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
|
||
const cur = continuing || live === null ? volumeRef.current : live;
|
||
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
|
||
};
|
||
|
||
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
|
||
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
|
||
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
|
||
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
|
||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
||
if (e.ctrlKey || e.metaKey) return;
|
||
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
|
||
// holds the description, which a wheel spent on the volume could never be read past (E31).
|
||
const panel =
|
||
e.target instanceof Element ? e.target.closest<HTMLElement>("[data-wheel-scrolls]") : null;
|
||
if (
|
||
panel &&
|
||
(e.deltaY < 0
|
||
? panel.scrollTop > 0
|
||
: panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights
|
||
)
|
||
return;
|
||
e.preventDefault();
|
||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||
});
|
||
useEffect(() => {
|
||
const el = dialogRef.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => onWheelSpin(e);
|
||
el.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => el.removeEventListener("wheel", onWheel);
|
||
}, [dialogRef]);
|
||
|
||
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
|
||
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
|
||
useEffect(
|
||
() => () => {
|
||
window.clearTimeout(volTimerRef.current);
|
||
if (volSaveTimerRef.current != null) persistVolume();
|
||
},
|
||
[],
|
||
);
|
||
|
||
return {
|
||
volumeUi,
|
||
reapplyStored: () => applyVolume(volumeRef.current, false),
|
||
nudgeVolume,
|
||
};
|
||
}
|
||
|
||
/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar /
|
||
* CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds
|
||
* focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to
|
||
* end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer
|
||
* leaves the video or the window regains focus. */
|
||
export function useNativeControlsYield({
|
||
playerRef,
|
||
stageRef,
|
||
focusModal,
|
||
}: {
|
||
playerRef: RefObject<YTPlayer | null>;
|
||
stageRef: RefObject<HTMLDivElement | null>;
|
||
focusModal: () => void;
|
||
}): {
|
||
nativeControls: boolean;
|
||
setNativeControls: (on: boolean) => void;
|
||
} {
|
||
const [nativeControls, setNativeControls] = useState(false);
|
||
const handOverToIframe = useEffectEvent(() => {
|
||
const p = playerRef.current;
|
||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||
if (f && document.activeElement === f) setNativeControls(true);
|
||
});
|
||
const rearmOurControls = useEffectEvent(() => {
|
||
setNativeControls(false);
|
||
focusModal();
|
||
});
|
||
useEffect(() => {
|
||
// activeElement settles on the tick after blur, so defer the check.
|
||
const onBlur = () => window.setTimeout(() => handOverToIframe(), 0);
|
||
const rearm = () => rearmOurControls();
|
||
const stage = stageRef.current;
|
||
window.addEventListener("blur", onBlur);
|
||
window.addEventListener("focus", rearm);
|
||
stage?.addEventListener("mouseleave", rearm);
|
||
return () => {
|
||
window.removeEventListener("blur", onBlur);
|
||
window.removeEventListener("focus", rearm);
|
||
stage?.removeEventListener("mouseleave", rearm);
|
||
};
|
||
}, [stageRef]);
|
||
return { nativeControls, setNativeControls };
|
||
}
|
||
|
||
/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport,
|
||
* deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working)
|
||
* and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on
|
||
* screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This
|
||
* is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the
|
||
* once-bound keyboard handler reads. */
|
||
export function useYtBigView({
|
||
stageRef,
|
||
playerRef,
|
||
focusModal,
|
||
}: {
|
||
stageRef: RefObject<HTMLDivElement | null>;
|
||
playerRef: RefObject<YTPlayer | null>;
|
||
focusModal: () => void;
|
||
}): {
|
||
isFullscreen: boolean;
|
||
maximized: boolean;
|
||
isFullscreenRef: RefObject<boolean>;
|
||
toggleFullscreen: () => void;
|
||
toggleTrueFullscreen: () => void;
|
||
exitBigView: () => void;
|
||
setMaximized: Dispatch<SetStateAction<boolean>>;
|
||
} {
|
||
// True while OUR stage is the fullscreen element (drives the overlay's pointer-events).
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||
const [maximized, setMaximized] = useState(false);
|
||
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
|
||
const isFullscreenRef = useRef(false);
|
||
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
|
||
// active). Mirrors the Esc ladder in the component's keydown handler.
|
||
useBackToExit(isFullscreen, exitFullscreen);
|
||
useBackToExit(maximized, () => setMaximized(false));
|
||
|
||
const toggleFullscreen = () => setMaximized((m) => !m);
|
||
// True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We
|
||
// never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold
|
||
// the lock" problem that made F avoid the Fullscreen API doesn't arise here.
|
||
const toggleTrueFullscreen = () => {
|
||
if (fullscreenElement()) exitFullscreen();
|
||
else if (stageRef.current) requestFullscreen(stageRef.current);
|
||
};
|
||
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
|
||
const exitBigView = () => {
|
||
if (fullscreenElement()) exitFullscreen();
|
||
setMaximized(false);
|
||
};
|
||
|
||
// Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is
|
||
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
|
||
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
|
||
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
|
||
const syncFullscreen = useEffectEvent(() => {
|
||
const fsEl = fullscreenElement();
|
||
const p = playerRef.current;
|
||
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||
if (!fsEl) focusModal();
|
||
});
|
||
useEffect(() => {
|
||
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
|
||
}, []);
|
||
|
||
return {
|
||
isFullscreen,
|
||
maximized,
|
||
isFullscreenRef,
|
||
toggleFullscreen,
|
||
toggleTrueFullscreen,
|
||
exitBigView,
|
||
setMaximized,
|
||
};
|
||
}
|
||
|
||
/** 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
|
||
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
|
||
* side-effect only, returns nothing. */
|
||
export function useYtProgress({
|
||
activeId,
|
||
resumeAt,
|
||
mountRef,
|
||
playerRef,
|
||
currentIdRef,
|
||
navRef,
|
||
modeRef,
|
||
setWatched,
|
||
setPlayerError,
|
||
setLiveData,
|
||
reapplyStored,
|
||
focusModal,
|
||
advanceOnEnd,
|
||
sessionPosRef,
|
||
qc,
|
||
}: {
|
||
activeId: string;
|
||
resumeAt: number;
|
||
mountRef: RefObject<HTMLDivElement | null>;
|
||
playerRef: RefObject<YTPlayer | null>;
|
||
currentIdRef: RefObject<string>;
|
||
navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
|
||
modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||
setWatched: (on: boolean) => void;
|
||
setPlayerError: (code: number | null) => void;
|
||
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||
reapplyStored: () => void;
|
||
focusModal: () => void;
|
||
advanceOnEnd: () => void;
|
||
// Positions this player session has actually STORED, per video id. Written from the server's
|
||
// answer, read back by the modal's resume so stepping next→prev inside one session does not
|
||
// rewind to the value the frozen launch queue was snapshotted with.
|
||
sessionPosRef: RefObject<Map<string, number>>;
|
||
qc: QueryClient;
|
||
}): 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);
|
||
|
||
// Auto-watch only applies to the active item — not to other videos the player navigated to via
|
||
// description links. Cap the finish margin at half the clip so a very short video isn't auto-marked
|
||
// almost immediately.
|
||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||
const margin = Math.min(FINISH_MARGIN, duration / 2);
|
||
if (current > duration - margin) {
|
||
autoMarkedRef.current = true;
|
||
setWatched(true);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
const persist = (): Promise<unknown> | void => {
|
||
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(() => {})
|
||
);
|
||
}
|
||
};
|
||
|
||
loadYouTubeApi()
|
||
.then((YT) => {
|
||
if (cancelled || !mountRef.current) return;
|
||
playerRef.current = new YT.Player(mountRef.current, {
|
||
width: "100%",
|
||
height: "100%",
|
||
videoId: id,
|
||
playerVars: {
|
||
autoplay: 1,
|
||
start: startAt || undefined,
|
||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||
enablejsapi: 1,
|
||
origin: window.location.origin,
|
||
playsinline: 1,
|
||
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
|
||
vq: "hd1080",
|
||
},
|
||
events: {
|
||
onReady: () => {
|
||
focusModal();
|
||
reapplyStored();
|
||
},
|
||
onStateChange: (e: YTPlayerEvent) => {
|
||
const p0 = playerRef.current;
|
||
logYouTubeState(
|
||
e?.data,
|
||
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
|
||
);
|
||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||
if (e?.data === 1) {
|
||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||
const p = playerRef.current;
|
||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||
if (d) setLiveData({ title: d.title, author: d.author });
|
||
}
|
||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||
if (e?.data === 0 && currentIdRef.current === id) {
|
||
if (!autoMarkedRef.current) {
|
||
autoMarkedRef.current = true;
|
||
setWatched(true);
|
||
}
|
||
advanceOnEnd();
|
||
}
|
||
},
|
||
onError: (e: YTPlayerEvent) => {
|
||
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
|
||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||
// items can't spin. We do NOT mark the broken video watched.
|
||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||
const { autoMode: a } = modeRef.current;
|
||
const { queue: q } = navRef.current;
|
||
if (a === "off" || !q || q.length <= 1) return;
|
||
errorStreakRef.current += 1;
|
||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||
advanceOnEnd();
|
||
},
|
||
},
|
||
});
|
||
})
|
||
// The script never arrived (blocked, offline, timed out) or the player could not be built:
|
||
// an error state with the way out, never an empty stage that waits forever.
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return;
|
||
logYouTubeApiFailed(err);
|
||
setPlayerError(YT_API_LOAD_FAILED);
|
||
});
|
||
|
||
// The session's positions map (mutated in place, never replaced) — held here for the cleanup.
|
||
const sessionPos = sessionPosRef.current;
|
||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||
const timer = window.setInterval(persist, 5000);
|
||
// 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 = () => {
|
||
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);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
window.removeEventListener("pagehide", onHide);
|
||
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
|
||
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
|
||
void Promise.resolve(persist()).finally(() => {
|
||
// Write this session's stored positions INTO the cached lists first. Marking them stale is
|
||
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
|
||
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
|
||
// fetched with — and the next checkpoint then persisted that older value over this one.
|
||
patchCachedProgress(qc, sessionPos);
|
||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
||
});
|
||
const p = playerRef.current;
|
||
if (p && typeof p.destroy === "function") {
|
||
try {
|
||
p.destroy();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
playerRef.current = null;
|
||
};
|
||
// 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).
|
||
//
|
||
// 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]);
|
||
}
|