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:
@@ -0,0 +1,16 @@
|
||||
import { createLucideIcon } from "lucide-react";
|
||||
|
||||
// Brand marks the app draws itself. Lucide deprecated its brand icons and will remove them, so the
|
||||
// YouTube glyph is held here instead — the same 24×24 stroke paths as lucide's `Youtube` (v0.408,
|
||||
// ISC licence, © Lucide contributors), built with lucide's own factory so it takes the same props
|
||||
// (`className`, `size`, `strokeWidth`) and renders identically next to every other icon.
|
||||
export const YouTubeIcon = createLucideIcon("YouTube", [
|
||||
[
|
||||
"path",
|
||||
{
|
||||
d: "M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",
|
||||
key: "1q2vi4",
|
||||
},
|
||||
],
|
||||
["path", { d: "m10 15 5-3-5-3z", key: "1jp15x" }],
|
||||
]);
|
||||
@@ -39,6 +39,7 @@ function legacyCopy(text: string): boolean {
|
||||
ta.style.height = "1px";
|
||||
ta.style.padding = "0";
|
||||
ta.style.border = "none";
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the iOS-tested hidden-but-selectable textarea (see the header); not re-styled blind
|
||||
ta.style.clip = "rect(0, 0, 0, 0)";
|
||||
ta.style.userSelect = "text";
|
||||
ta.style.setProperty("-webkit-user-select", "text");
|
||||
@@ -47,6 +48,7 @@ function legacyCopy(text: string): boolean {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the only SYNCHRONOUS copy: iOS Safari needs it inside the click gesture, and a non-secure origin has no navigator.clipboard
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -207,6 +207,9 @@ export function useHistorySubview<T>(
|
||||
type Overlay = { token: number; cb: () => void };
|
||||
const overlayStack: Overlay[] = [];
|
||||
let overlayCounter = 0;
|
||||
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
|
||||
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
|
||||
const nextOverlayToken = () => ++overlayCounter;
|
||||
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||||
let historyDepth = 0;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
@@ -222,7 +225,7 @@ function reconcileHistory(): void {
|
||||
const want = overlayStack.length;
|
||||
while (historyDepth < want) {
|
||||
historyDepth++;
|
||||
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
@@ -371,7 +374,7 @@ export function useBackToClose(onClose: () => void): void {
|
||||
cb.current = onClose;
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
@@ -396,7 +399,7 @@ export function useBackToExit(active: boolean, onExit: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
|
||||
@@ -9,7 +9,31 @@ import { beforeAll } from "vitest";
|
||||
// hook below pays that cost once per test file, under its own budget; importing this registers it.
|
||||
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
export const eslint = new ESLint({ cwd: FRONTEND });
|
||||
// The probes lint TEXT under file names that do not exist (`__probe.tsx`, `x.ts`, a feature-folder
|
||||
// `Button.tsx`). Since E32 the src config runs type-aware rules, whose project service refuses a
|
||||
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
|
||||
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
||||
const PROBE_FILES = [
|
||||
"src/*/__*probe.ts",
|
||||
"src/*/__*probe.tsx",
|
||||
"src/components/ui/__*probe.tsx",
|
||||
"src/lib/api/__*probe.ts",
|
||||
"src/components/Button.tsx",
|
||||
"src/lib/x.ts",
|
||||
];
|
||||
|
||||
export const eslint = new ESLint({
|
||||
cwd: FRONTEND,
|
||||
overrideConfig: {
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: { allowDefaultProject: PROBE_FILES, defaultProject: "tsconfig.json" },
|
||||
tsconfigRootDir: FRONTEND,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
|
||||
|
||||
@@ -88,3 +88,11 @@ export function logYouTubeState(state: number, at: number): void {
|
||||
export function logYouTubeError(code: number): void {
|
||||
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code });
|
||||
}
|
||||
|
||||
/** The IFrame API script never arrived (blocked, offline, timed out), so there is no player at all. */
|
||||
export function logYouTubeApiFailed(err: unknown): void {
|
||||
log.warn("player.yt_api_failed", "the YouTube player could not be loaded", {
|
||||
player: "youtube",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "./brandIcons";
|
||||
import type { Me } from "./api";
|
||||
import type { Page } from "./urlState";
|
||||
|
||||
@@ -62,7 +62,7 @@ const MODULES: readonly ModuleDef[] = [
|
||||
titleKey: "home.title",
|
||||
icon: Home,
|
||||
},
|
||||
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: Youtube },
|
||||
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: YouTubeIcon },
|
||||
{
|
||||
id: "channels",
|
||||
labelKey: "header.account.channels",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The YouTube IFrame API script failing to load must END in an error state. `loadYouTubeApi` used to
|
||||
// return a promise that only ever resolved: a blocked or failed script (a content blocker, no
|
||||
// network) left the modal on an empty stage forever, with no error and no way to retry short of a
|
||||
// reload — the promise was memoised, so every later open waited on the same dead one (E32 audit).
|
||||
//
|
||||
// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
|
||||
// and these tests need a module instance that has never seen `window.YT`.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { useYtProgress, YT_API_LOAD_FAILED, YT_API_TIMEOUT_MS } from "./playerModalHooks";
|
||||
import type { YTPlayer } from "./youtube";
|
||||
|
||||
const VID = "EC5oFYy9z9g";
|
||||
|
||||
const apiScripts = () =>
|
||||
[...document.head.querySelectorAll("script")].filter((s) => s.src.includes("iframe_api"));
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function mountPlayer(setPlayerError: (code: number | null) => void) {
|
||||
const mount = document.createElement("div");
|
||||
document.body.appendChild(mount);
|
||||
return renderHook(() =>
|
||||
useYtProgress({
|
||||
activeId: VID,
|
||||
resumeAt: 0,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
navRef: { current: { queue: undefined, index: 0 } },
|
||||
modeRef: { current: { autoMode: "off", loopMode: "off" } },
|
||||
setWatched: () => {},
|
||||
setPlayerError,
|
||||
setLiveData: () => {},
|
||||
reapplyStored: () => {},
|
||||
focusModal: () => {},
|
||||
advanceOnEnd: () => {},
|
||||
sessionPosRef: { current: new Map() },
|
||||
qc: new QueryClient(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useYtProgress — the IFrame API failing to load", () => {
|
||||
// Fail any load still in flight, so the module's memoised promise is cleared for the next test
|
||||
// (a pending one would carry a timer from before that test's fake clock).
|
||||
afterEach(async () => {
|
||||
for (const s of apiScripts()) s.dispatchEvent(new Event("error"));
|
||||
await settle();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the load-failed state when the script errors, and a later open tries again", async () => {
|
||||
const setPlayerError = vi.fn();
|
||||
const first = mountPlayer(setPlayerError);
|
||||
const [tag] = apiScripts();
|
||||
expect(tag).toBeDefined();
|
||||
tag!.dispatchEvent(new Event("error"));
|
||||
await settle();
|
||||
expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
|
||||
first.unmount();
|
||||
|
||||
// Not memoised as a failure: the next open loads the script again rather than waiting on the
|
||||
// dead promise.
|
||||
const again = mountPlayer(vi.fn());
|
||||
expect(apiScripts()).toHaveLength(1);
|
||||
again.unmount();
|
||||
});
|
||||
|
||||
it("gives up after the timeout when the script never answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setPlayerError = vi.fn();
|
||||
const { unmount } = mountPlayer(setPlayerError);
|
||||
await vi.advanceTimersByTimeAsync(YT_API_TIMEOUT_MS + 1);
|
||||
expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createRef, type MutableRefObject } from "react";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { useYtProgress } from "./playerModalHooks";
|
||||
import { patchCachedProgress } from "./progressCache";
|
||||
import { qk } from "./queryKeys";
|
||||
@@ -73,7 +73,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
|
||||
activeId: VID,
|
||||
resumeAt: 748,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as MutableRefObject<YTPlayer | null>,
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
navRef: { current: { queue: undefined, index: 0 } },
|
||||
modeRef: { current: { autoMode: "off", loopMode: "off" } },
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("useControlsVisibility", () => {
|
||||
it("hides 3 s after the last activity while playing", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.wake());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
});
|
||||
|
||||
@@ -36,16 +38,45 @@ describe("useControlsVisibility", () => {
|
||||
result.current.wake();
|
||||
result.current.holdOnHover(true);
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true);
|
||||
|
||||
act(() => result.current.holdOnHover(false));
|
||||
act(() => vi.advanceTimersByTime(2900));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2900);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // a fresh 3 s from the leave, not from the wake
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
});
|
||||
|
||||
// E28 E6 / round 2 P4: a keyboard focus in the chrome holds the bar — but only for FOCUS_HOLD_MS
|
||||
// after the last key, not for the whole film. The 3 s timer finds the hold, re-arms for what is
|
||||
// left of it, and hides once it is over (E32 rewrote that re-arm without recursion).
|
||||
it("holds for a keyboard focus until 10 s after the last key, then hides", () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.holdOnFocus(true);
|
||||
result.current.wake();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // the hold, not the 3 s
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(6900);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // 9.9 s after the key
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false); // the hold is over, focus or not
|
||||
});
|
||||
|
||||
// C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
|
||||
it("toggle hides a shown bar at once and shows a hidden one", () => {
|
||||
const { result } = setup();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRef, type ReactNode, type MutableRefObject } from "react";
|
||||
import { createRef, type ReactNode, type RefObject } from "react";
|
||||
import { usePlexTracks } from "./plexPlayerHooks";
|
||||
import { api, type PlexItemDetail } from "./api";
|
||||
import { dismissError, getError } from "./errorDialog";
|
||||
@@ -49,7 +49,7 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
|
||||
videoRef: createRef<HTMLVideoElement>(),
|
||||
hlsRef: createRef<Hls>(),
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
audioRef: { current: 0 } as RefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: abs },
|
||||
loadStartRef: { current: loadStart },
|
||||
@@ -325,7 +325,7 @@ function TrackHarness() {
|
||||
videoRef: createRef<HTMLVideoElement>(),
|
||||
hlsRef: createRef<Hls>(),
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
audioRef: { current: 0 } as RefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: 3600 },
|
||||
loadStartRef: { current: 0 },
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
@@ -113,16 +112,23 @@ export function useControlsVisibility(opts: {
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
|
||||
const wokeAt = Date.now();
|
||||
const arm = (ms: number) => {
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return;
|
||||
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
|
||||
if (focused.current && held > 0) return arm(held);
|
||||
setVisible(false);
|
||||
}, ms);
|
||||
};
|
||||
arm(HIDE_MS);
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
const busy = () => !!videoRef.current?.paused || menuOpenRef.current || hovered.current;
|
||||
// Not a timer that re-arms itself: a self-calling `arm` read to the compiler's analysis as a
|
||||
// value from outside this callback (memo-dependencies), and a self-naming function expression
|
||||
// tripped one of its invariants. The focus hold re-arms at most once — the second timer fires
|
||||
// when the hold is over, so it has only `busy` left to ask.
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
if (busy()) return;
|
||||
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
|
||||
if (focused.current && held > 0) {
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
if (!busy()) setVisible(false);
|
||||
}, held);
|
||||
return;
|
||||
}
|
||||
setVisible(false);
|
||||
}, HIDE_MS);
|
||||
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
|
||||
const show = useCallback(
|
||||
(visible: boolean) => {
|
||||
@@ -188,8 +194,8 @@ export function usePlexProgress(opts: {
|
||||
id: string;
|
||||
qc: QueryClient;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
absRef: MutableRefObject<number>;
|
||||
durationRef: MutableRefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
}): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } {
|
||||
const { id, qc, videoRef, absRef, durationRef } = opts;
|
||||
const settleRef = useRef<() => void>(() => {});
|
||||
@@ -341,10 +347,7 @@ export function usePlexProgress(opts: {
|
||||
* 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 (deep-review round 4). */
|
||||
export function playhead(
|
||||
absRef: MutableRefObject<number>,
|
||||
loadStartRef: MutableRefObject<number>,
|
||||
): number {
|
||||
export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
|
||||
return absRef.current > 0 ? absRef.current : loadStartRef.current;
|
||||
}
|
||||
|
||||
@@ -439,8 +442,8 @@ export function useAutoSkip(opts: {
|
||||
}): {
|
||||
activeMarker: PlexMarker | undefined;
|
||||
skipProgress: number | null;
|
||||
skipProgressRef: MutableRefObject<number | null>;
|
||||
cancelAutoSkipRef: MutableRefObject<() => void>;
|
||||
skipProgressRef: RefObject<number | null>;
|
||||
cancelAutoSkipRef: RefObject<() => void>;
|
||||
doSkip: (m: PlexMarker) => void;
|
||||
} {
|
||||
const {
|
||||
@@ -594,12 +597,12 @@ export function usePlexTracks(opts: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
multiAudioRef: RefObject<boolean>;
|
||||
audioRef: MutableRefObject<number | null>;
|
||||
aoffRef: MutableRefObject<number>;
|
||||
absRef: MutableRefObject<number>;
|
||||
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: MutableRefObject<number>;
|
||||
loadStartRef: RefObject<number>;
|
||||
}): {
|
||||
audioOrd: number | null;
|
||||
setAudioOrd: (ord: number | null) => void;
|
||||
@@ -677,7 +680,7 @@ export function usePlexTracks(opts: {
|
||||
}
|
||||
} else {
|
||||
// Single audio (or the session has not answered yet): re-map the track server-side.
|
||||
loadSession(playhead(absRef, loadStartRef));
|
||||
void loadSession(playhead(absRef, loadStartRef));
|
||||
}
|
||||
},
|
||||
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
|
||||
|
||||
@@ -16,7 +16,9 @@ describe("useDebounced", () => {
|
||||
});
|
||||
rerender({ v: "ab" });
|
||||
expect(result.current).toBe("a");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(result.current).toBe("ab");
|
||||
});
|
||||
|
||||
@@ -29,7 +31,9 @@ describe("useDebounced", () => {
|
||||
expect(result.current).toBe("boom");
|
||||
rerender({ v: "boomx", s: 1 });
|
||||
expect(result.current).toBe("boom");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(result.current).toBe("boomx");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +143,9 @@ describe("usePref", () => {
|
||||
// Local state is instant; the server write is deferred.
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(savePrefs).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
|
||||
});
|
||||
|
||||
@@ -124,10 +124,10 @@ export function usePref<T>(
|
||||
pendingSave.current = flush;
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
pendingSave.current = null;
|
||||
flush();
|
||||
void flush();
|
||||
}, debounceMs);
|
||||
} else {
|
||||
flush();
|
||||
void flush();
|
||||
}
|
||||
},
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
|
||||
Reference in New Issue
Block a user