refactor(fe): eliminate every explicit any; enforce no-explicit-any (R7 S3a·4)

Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
  narrowed at consumers (App language guard; NotificationsPanel shapes the
  notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
  (SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
  YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
  tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction

Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
This commit is contained in:
2026-07-27 22:57:33 +02:00
parent 2d4ecdb6c5
commit 90749a9e45
9 changed files with 102 additions and 32 deletions
+10 -8
View File
@@ -30,6 +30,7 @@ import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
@@ -82,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
}
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null;
function loadYouTubeApi(): Promise<any> {
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => {
const w = window as any;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev();
resolve(w.YT);
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
@@ -158,7 +160,7 @@ export default function PlayerModal({
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// 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.
@@ -706,7 +708,7 @@ export default function PlayerModal({
focusModal();
applyVolume(volumeRef.current, false);
},
onStateChange: (e: any) => {
onStateChange: (e: YTPlayerEvent) => {
// 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
@@ -725,7 +727,7 @@ export default function PlayerModal({
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => {
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but