build(frontend): close the lint gaps the E32 audit measured
eslint now also enforces: - the React Compiler diagnostics that `recommended` leaves out (todo, memo-dependencies and ten zero-hit tripwires); - a description on every suppression, and no blanket disables; - no focused or skipped tests (vitest and playwright); - the typed pair no-floating-promises and no-deprecated. Lint also covers e2e/ and the TypeScript build configs, which `eslint .` had been skipping in silence. Every hit is fixed or suppressed inline with a named reason: - The 31 try statements the compiler cannot lower yet carry a suppression until S3 rewrites them. Five other compiler skips were plain fixes: a module counter bumped inside a hook, a ref read in a default parameter, and a local named like the <section> it renders. - The real defects behind floating promises are fixed. ChatDock threw at nobody when the device key store could not be opened. VideoEditor's play() rejection went unhandled. loadYouTubeApi never rejected, so a blocked IFrame API left the player on an empty stage forever; it now fails on a script error or after 15 s, shows its own error with the way out, and lets the next open try again. - MutableRefObject, FormEvent and lucide's deprecated brand icon are replaced. The YouTube glyph now lives in lib/brandIcons. - The lint-probe harness lets its virtual file names into TypeScript's default project, so the contract tests still run the real config.
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
@@ -22,7 +21,7 @@ import { api, type Video } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { patchCachedProgress } from "./progressCache";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||
import { logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||
import type { AutoMode, LoopMode } from "./playerModes";
|
||||
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
@@ -32,19 +31,36 @@ const FINISH_MARGIN = 10;
|
||||
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) => {
|
||||
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!);
|
||||
};
|
||||
const tag = document.createElement("script");
|
||||
tag.onerror = () => fail("failed to load");
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
@@ -67,7 +83,7 @@ export function useYtVolume({
|
||||
dialogRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
dialogRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
@@ -188,7 +204,7 @@ export function useNativeControlsYield({
|
||||
stageRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
@@ -234,13 +250,13 @@ export function useYtBigView({
|
||||
focusModal,
|
||||
}: {
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
maximizedRef: MutableRefObject<boolean>;
|
||||
isFullscreenRef: MutableRefObject<boolean>;
|
||||
maximizedRef: RefObject<boolean>;
|
||||
isFullscreenRef: RefObject<boolean>;
|
||||
toggleFullscreen: () => void;
|
||||
toggleTrueFullscreen: () => void;
|
||||
exitBigView: () => void;
|
||||
@@ -327,10 +343,10 @@ export function useYtProgress({
|
||||
activeId: string;
|
||||
resumeAt: number;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
currentIdRef: MutableRefObject<string>;
|
||||
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
|
||||
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||||
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;
|
||||
@@ -340,7 +356,7 @@ export function useYtProgress({
|
||||
// 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: MutableRefObject<Map<string, number>>;
|
||||
sessionPosRef: RefObject<Map<string, number>>;
|
||||
qc: QueryClient;
|
||||
}): void {
|
||||
const autoMarkedRef = useRef(false);
|
||||
@@ -391,66 +407,74 @@ export function useYtProgress({
|
||||
}
|
||||
};
|
||||
|
||||
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: resumeAt || 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();
|
||||
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: resumeAt || 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",
|
||||
},
|
||||
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);
|
||||
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();
|
||||
}
|
||||
},
|
||||
},
|
||||
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;
|
||||
@@ -477,7 +501,7 @@ export function useYtProgress({
|
||||
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).
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
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
|
||||
@@ -511,6 +535,6 @@ export function useYtProgress({
|
||||
//
|
||||
// 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
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ONE player per video: the captured values are per-item snapshots, live ones are refs
|
||||
}, [activeId]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user