From 471846d7844278a774f791f053cbc50e633d5aa5 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:34:20 +0200
Subject: [PATCH] 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 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.
---
frontend/eslint.config.js | 110 ++++++-
frontend/package-lock.json | 299 ++++++++++++++++++
frontend/package.json | 3 +
frontend/src/components/AddToPlaylist.tsx | 7 +-
frontend/src/components/BootGate.dom.test.tsx | 15 +-
.../ChannelPage.backfill.dom.test.tsx | 4 +-
.../components/ChannelsProvider.dom.test.tsx | 10 +-
frontend/src/components/ChatDock.tsx | 13 +-
frontend/src/components/ChatThread.tsx | 3 +-
frontend/src/components/DownloadDialog.tsx | 2 +-
frontend/src/components/Feed.tsx | 8 +-
frontend/src/components/Header.tsx | 4 +-
frontend/src/components/IssueComposer.tsx | 6 +-
frontend/src/components/KeyGate.tsx | 2 +
frontend/src/components/Messages.tsx | 3 +-
.../NavigationProvider.dom.test.tsx | 4 +-
frontend/src/components/OnboardingWizard.tsx | 7 +-
frontend/src/components/PlayerModal.tsx | 10 +-
frontend/src/components/Playlists.tsx | 15 +-
frontend/src/components/PlaylistsRail.tsx | 6 +-
frontend/src/components/PlexBrowse.tsx | 4 +-
.../src/components/PlexCollectionEditor.tsx | 4 +
.../src/components/PlexCollectionView.tsx | 6 +-
.../PlexPlayer.controls.dom.test.tsx | 44 ++-
.../PlexPlayer.session.dom.test.tsx | 4 +-
frontend/src/components/PlexPlayer.tsx | 10 +-
frontend/src/components/PlexPlaylistAdd.tsx | 4 +-
frontend/src/components/PlexPlaylistView.tsx | 2 +-
frontend/src/components/PlexSidebar.tsx | 3 +-
.../src/components/PrefsProvider.dom.test.tsx | 5 +-
frontend/src/components/ProfileEditor.tsx | 2 +-
.../SavedViewsProvider.dom.test.tsx | 4 +-
.../src/components/SavedViewsProvider.tsx | 3 +-
.../components/Scheduler.menu.dom.test.tsx | 4 +-
frontend/src/components/SettingsPanel.tsx | 4 +-
frontend/src/components/SetupWizard.tsx | 1 +
frontend/src/components/ShareDialog.tsx | 2 +-
frontend/src/components/VideoEditor.tsx | 5 +-
frontend/src/components/WatchPage.tsx | 2 +
frontend/src/components/Welcome.tsx | 6 +-
.../src/components/accountPanel.dom.test.tsx | 4 +-
frontend/src/components/legal/LegalLayout.tsx | 2 +-
.../src/components/plexCards.dom.test.tsx | 12 +-
frontend/src/components/plexViews.tsx | 10 +-
.../src/components/ui/AnchoredPopover.tsx | 2 +-
frontend/src/components/ui/Toaster.tsx | 2 +-
frontend/src/components/ui/overlayOrder.ts | 4 +-
frontend/src/components/ui/sortableA11y.ts | 2 +-
frontend/src/components/useDebugRecording.ts | 1 +
.../src/components/useDownloadActions.tsx | 1 +
.../src/components/usePlaylistActions.tsx | 7 +-
.../usePlexPlaylistEdits.dom.test.tsx | 6 +-
.../src/components/usePlexPlaylistEdits.ts | 1 +
frontend/src/design-lab/batchB.tsx | 4 +-
frontend/src/i18n/locales/en/player.json | 1 +
frontend/src/i18n/locales/hu/player.json | 1 +
frontend/src/lib/brandIcons.ts | 16 +
frontend/src/lib/clipboard.ts | 2 +
frontend/src/lib/history.ts | 9 +-
frontend/src/lib/lintProbe.testkit.ts | 26 +-
frontend/src/lib/mediaLog.ts | 8 +
frontend/src/lib/modules.ts | 4 +-
.../lib/playerModalHooks.apiload.dom.test.tsx | 84 +++++
.../playerModalHooks.progress.dom.test.tsx | 4 +-
frontend/src/lib/playerModalHooks.ts | 166 +++++-----
.../lib/plexPlayerHooks.controls.dom.test.tsx | 39 ++-
.../lib/plexPlayerHooks.subtitle.dom.test.tsx | 6 +-
frontend/src/lib/plexPlayerHooks.ts | 51 +--
frontend/src/lib/useDebounced.dom.test.ts | 8 +-
frontend/src/lib/usePref.dom.test.ts | 4 +-
frontend/src/lib/usePref.ts | 4 +-
frontend/tsconfig.node.json | 13 +-
72 files changed, 938 insertions(+), 216 deletions(-)
create mode 100644 frontend/src/lib/brandIcons.ts
create mode 100644 frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 3d1b998a..cf5cc369 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -4,6 +4,9 @@ import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import prettier from "eslint-config-prettier";
import globals from "globals";
+import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
+import vitest from "@vitest/eslint-plugin";
+import playwright from "eslint-plugin-playwright";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
@@ -14,6 +17,11 @@ import globals from "globals";
//
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
+//
+// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
+// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
+// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
+// a `-- ` description (eslint-comments/require-description makes that mandatory).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
@@ -472,25 +480,83 @@ const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
+// Applies to EVERY linted file: a suppression says which rule and why. A bare
+// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
+// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
+const suppressionRules = {
+ plugins: { "@eslint-community/eslint-comments": eslintComments },
+ linterOptions: { reportUnusedDisableDirectives: "error" },
+ rules: {
+ "@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }],
+ "@eslint-community/eslint-comments/no-unlimited-disable": "error",
+ },
+};
+
+// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
+// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
+// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget
+// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
+// unless asked to (`throwOnError`), so those calls are known-safe by name.
+const typedRules = {
+ "@typescript-eslint/no-floating-promises": [
+ "error",
+ {
+ allowForKnownSafeCalls: [
+ {
+ from: "package",
+ package: "@tanstack/query-core",
+ name: [
+ "invalidateQueries",
+ "refetchQueries",
+ "cancelQueries",
+ "resetQueries",
+ "prefetchQuery",
+ "prefetchInfiniteQuery",
+ ],
+ },
+ ],
+ },
+ ],
+ "@typescript-eslint/no-deprecated": "error",
+};
+
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
+ suppressionRules,
{
files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser },
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
local: localRules,
},
- linterOptions: {
- reportUnusedDisableDirectives: "error",
- },
rules: {
...reactHooks.configs.recommended.rules,
+ ...typedRules,
+ // The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
+ // the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
+ // error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
+ // this shape yet" (31 try statements, each suppressed inline until E32 S3 rewrites them); the
+ // rest had 0 hits when switched on and are tripwires. The full per-function picture is
+ // components/compilerCoverage.contract.test.ts.
+ "react-hooks/todo": "error",
+ "react-hooks/memo-dependencies": "error",
+ "react-hooks/rule-suppression": "error",
+ "react-hooks/invariant": "error",
+ "react-hooks/syntax": "error",
+ "react-hooks/void-use-memo": "error",
+ "react-hooks/component-hook-factories": "error",
+ "react-hooks/capitalized-calls": "error",
+ "react-hooks/hooks": "error",
+ "react-hooks/memoized-effect-dependencies": "error",
+ "react-hooks/exhaustive-effect-dependencies": "error",
+ "react-hooks/no-deriving-state-in-effects": "error",
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
// triaged rather than bulk-suppressed, because the two answers are different:
@@ -709,7 +775,12 @@ export default tseslint.config(
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
files: ["src/**/*.test.{ts,tsx}"],
+ plugins: { vitest },
rules: {
+ // A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
+ // (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
+ "vitest/no-focused-tests": "error",
+ "vitest/no-disabled-tests": "error",
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
@@ -720,5 +791,38 @@ export default tseslint.config(
"local/glyph-link": "off",
},
},
+ {
+ // Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
+ // flat config named only src/**, so `eslint .` skipped them without a word. The same base and
+ // typed rules as the app; Node globals, since none of it runs in the browser.
+ files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: { ...globals.node },
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
+ },
+ rules: {
+ ...typedRules,
+ // tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict +
+ // noUnusedLocals/Parameters).
+ "@typescript-eslint/no-unused-vars": "off",
+ "no-undef": "off",
+ },
+ },
+ {
+ files: ["e2e/**/*.ts"],
+ plugins: { playwright },
+ rules: {
+ "playwright/no-focused-test": "error",
+ "playwright/no-skipped-test": "error",
+ },
+ },
+ {
+ // The two plain-JS configs: this file and postcss's.
+ files: ["*.config.js"],
+ extends: [js.configs.recommended],
+ languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } },
+ },
prettier,
);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 78063d2e..a049ed8d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
@@ -32,9 +33,11 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
@@ -970,6 +973,36 @@
"node": ">=12"
}
},
+ "node_modules/@eslint-community/eslint-plugin-eslint-comments": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz",
+ "integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "ignore": "^7.0.5"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": {
+ "version": "7.0.12",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
+ "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -3236,6 +3269,243 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/eslint-plugin": {
+ "version": "1.6.27",
+ "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz",
+ "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/eslint-plugin": "*",
+ "eslint": ">=8.57.0",
+ "typescript": ">=5.0.0",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/eslint-plugin": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
+ "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.71.0",
+ "@typescript-eslint/types": "^8.71.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
+ "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
+ "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
+ "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
+ "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.71.0",
+ "@typescript-eslint/tsconfig-utils": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
+ "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
+ "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -4087,6 +4357,35 @@
"eslint": ">=7.0.0"
}
},
+ "node_modules/eslint-plugin-playwright": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.0.tgz",
+ "integrity": "sha512-7HpSKR/D2oCH4RKRauFskcrnDrHvLQKopW7Nen8lqrFZtx/SB+FgqVaH+zyQd32noZSZy9Lrsa+DsYrVUpn6hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "globals": "^17.3.0"
+ },
+ "engines": {
+ "node": ">=16.9.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=8.40.0"
+ }
+ },
+ "node_modules/eslint-plugin-playwright/node_modules/globals": {
+ "version": "17.13.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
+ "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e9bd1f67..64620f6a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
@@ -48,9 +49,11 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx
index 6ece7882..d4179213 100644
--- a/frontend/src/components/AddToPlaylist.tsx
+++ b/frontend/src/components/AddToPlaylist.tsx
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
-import { ListPlus, Plus, Youtube } from "lucide-react";
+import { ListPlus, Plus } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -117,6 +118,7 @@ function MembershipPanel({
async function toggle(pl: Playlist) {
if (busy) return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
@@ -132,6 +134,7 @@ function MembershipPanel({
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
@@ -183,7 +186,7 @@ function MembershipPanel({
{playlistName(pl, t)}
{(pl.source === "youtube" || pl.yt_playlist_id) && (
-
+
)}
))}
diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx
index a81f4c74..e436e12c 100644
--- a/frontend/src/components/BootGate.dom.test.tsx
+++ b/frontend/src/components/BootGate.dom.test.tsx
@@ -23,13 +23,14 @@ import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
+const countMount = () => {
+ mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
+};
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
- useEffect(() => {
- mounts++;
- }, []);
+ useEffect(countMount, []);
return {page}
;
}
@@ -226,7 +227,9 @@ describe("BootGate", () => {
const qc = mount();
await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
- act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
+ act(() => {
+ qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
+ });
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
@@ -241,7 +244,9 @@ describe("BootGate", () => {
vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
- act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
+ act(() => {
+ qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
+ });
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
diff --git a/frontend/src/components/ChannelPage.backfill.dom.test.tsx b/frontend/src/components/ChannelPage.backfill.dom.test.tsx
index 673a714d..8f060b47 100644
--- a/frontend/src/components/ChannelPage.backfill.dom.test.tsx
+++ b/frontend/src/components/ChannelPage.backfill.dom.test.tsx
@@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => {
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
// The next poll tick: the background job has stored 60 more.
- act(() => qc.setQueryData(qk.channel(CH), reading(79)));
+ act(() => {
+ qc.setQueryData(qk.channel(CH), reading(79));
+ });
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
});
diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx
index c5b86cc9..cdd36b56 100644
--- a/frontend/src/components/ChannelsProvider.dom.test.tsx
+++ b/frontend/src/components/ChannelsProvider.dom.test.tsx
@@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("blocked");
// Browser Back to an entry stamped with a different tab.
- act(() =>
- window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
- );
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
+ });
expect(result.current.view).toBe("discovery");
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
@@ -71,7 +71,9 @@ describe("ChannelsProvider tab history", () => {
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
const { result } = render();
act(() => result.current.setView("hidden"));
- act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
+ });
expect(result.current.view).toBe("hidden"); // unchanged
});
});
diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx
index 957ffb9e..db984d3d 100644
--- a/frontend/src/components/ChatDock.tsx
+++ b/frontend/src/components/ChatDock.tsx
@@ -13,6 +13,7 @@ import {
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
+import { errorAttrs, log } from "../lib/log";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
@@ -30,7 +31,17 @@ export default function ChatDock({ meId }: { meId: number }) {
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => {
initDock(meId);
- e2ee.loadFromDevice(meId);
+ // The device key store can be unopenable (storage disabled, some private windows): messages then
+ // stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
+ e2ee
+ .loadFromDevice(meId)
+ .catch((err: unknown) =>
+ log.warn(
+ "ui.e2ee_device_key_unreadable",
+ "the device key store could not be read",
+ errorAttrs(err),
+ ),
+ );
}, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
index f6249c5e..6cdd4ee5 100644
--- a/frontend/src/components/ChatThread.tsx
+++ b/frontend/src/components/ChatThread.tsx
@@ -54,7 +54,8 @@ export default function ChatThread({
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
- (async () => {
+ // Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
+ void (async () => {
const out: Record = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx
index 8594101e..eab36ba5 100644
--- a/frontend/src/components/DownloadDialog.tsx
+++ b/frontend/src/components/DownloadDialog.tsx
@@ -104,7 +104,7 @@ export default function DownloadDialog({
{
setManage(false);
- profilesQ.refetch();
+ void profilesQ.refetch();
}}
/>
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index bd10d7e1..aee20101 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -19,9 +19,9 @@ import {
RefreshCw,
Rows3,
Trash2,
- Youtube,
type LucideIcon,
} from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
@@ -352,7 +352,7 @@ export default function Feed({
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
const onStateRef = useRef(onState);
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
onStateRef.current = onState;
const onResetState = useCallback(
@@ -473,7 +473,7 @@ export default function Feed({
{t("feed.yt.back")}
-
+
{t("feed.yt.resultsFor", { query: ytSearch })}
@@ -882,7 +882,7 @@ export default function Feed({
{filters.q.trim() && !isDemo && (
}
+ icon={}
onClick={() => onYtSearch(filters.q.trim())}
>
{t("feed.yt.searchFor", { query: filters.q.trim() })}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 0c7e2016..8729df4a 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,6 +1,6 @@
import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { Youtube } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
@@ -61,7 +61,7 @@ const Header = forwardRef<
onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t("header.searchYoutubeTip")}
- goIcon={}
+ goIcon={}
goDisabled={!trimmed || me.is_demo}
/>
);
diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx
index 09b3f1d0..80d669da 100644
--- a/frontend/src/components/IssueComposer.tsx
+++ b/frontend/src/components/IssueComposer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
+import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
@@ -606,7 +606,7 @@ function Attachments({
adding: boolean;
onAdd: (files: Iterable) => void;
onReview: (source: Source) => void;
- reviewed: MutableRefObject;
+ reviewed: RefObject;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -615,7 +615,7 @@ function Attachments({
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef(null);
const reviewButtons = useRef>>({});
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- the effect that consumes the caller's one-shot focus token (see the write below)
useEffect(() => {
const source = reviewed.current;
if (!source) return;
diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx
index d04b17dc..af4a5258 100644
--- a/frontend/src/components/KeyGate.tsx
+++ b/frontend/src/components/KeyGate.tsx
@@ -44,6 +44,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
@@ -76,6 +77,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx
index 1c1f0742..54ee85a0 100644
--- a/frontend/src/components/Messages.tsx
+++ b/frontend/src/components/Messages.tsx
@@ -100,7 +100,8 @@ function ConversationList({
useEffect(() => {
if (!ready) return;
let cancelled = false;
- (async () => {
+ // Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
+ void (async () => {
const out: Record = {};
for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message;
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index 764709d6..d7f36e40 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -84,7 +84,9 @@ describe("NavigationProvider · the tab title", () => {
await settle();
expect(document.title).toBe("Siftlode");
- act(() => qc.setQueryData(qk.me(), { id: 1 }));
+ act(() => {
+ qc.setQueryData(qk.me(), { id: 1 });
+ });
await settle();
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx
index 5fb27379..121585ae 100644
--- a/frontend/src/components/OnboardingWizard.tsx
+++ b/frontend/src/components/OnboardingWizard.tsx
@@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
-import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
+import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import { Button } from "./ui/Button";
@@ -186,7 +187,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
{step === "read" && (
<>
-
+
{t("onboarding.read.title")}
@@ -199,7 +200,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-
} onClick={() => beginGrant("read")}>
+
} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")}
)}
diff --git a/frontend/src/components/ui/overlayOrder.ts b/frontend/src/components/ui/overlayOrder.ts
index d04a8df5..28be5f58 100644
--- a/frontend/src/components/ui/overlayOrder.ts
+++ b/frontend/src/components/ui/overlayOrder.ts
@@ -3,9 +3,11 @@ import { useState } from "react";
// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports
// only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
let overlaySeq = 0;
+// Outside the hook: the React Compiler cannot lower `++` on a module variable inside one (react-hooks/todo).
+const nextOverlaySeq = () => ++overlaySeq;
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
* itself, never in a component that can remount while the overlay stays open. */
export function useOverlayOrder(): number {
- const [order] = useState(() => ++overlaySeq);
+ const [order] = useState(nextOverlaySeq);
return order;
}
diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts
index 3c4dd5bc..15f721b1 100644
--- a/frontend/src/components/ui/sortableA11y.ts
+++ b/frontend/src/components/ui/sortableA11y.ts
@@ -65,7 +65,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
// the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component.
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
diff --git a/frontend/src/components/useDebugRecording.ts b/frontend/src/components/useDebugRecording.ts
index b5b39269..0e8c5d37 100644
--- a/frontend/src/components/useDebugRecording.ts
+++ b/frontend/src/components/useDebugRecording.ts
@@ -106,6 +106,7 @@ export function useDebugRecording() {
if (!session) return;
hide(true);
let image: Blob | null = null;
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
diff --git a/frontend/src/components/useDownloadActions.tsx b/frontend/src/components/useDownloadActions.tsx
index 3e31a8dd..4726d893 100644
--- a/frontend/src/components/useDownloadActions.tsx
+++ b/frontend/src/components/useDownloadActions.tsx
@@ -89,6 +89,7 @@ export function useDownloadActions({
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await quickShareLink(qc, job, t);
} finally {
diff --git a/frontend/src/components/usePlaylistActions.tsx b/frontend/src/components/usePlaylistActions.tsx
index c502fb7f..2938e658 100644
--- a/frontend/src/components/usePlaylistActions.tsx
+++ b/frontend/src/components/usePlaylistActions.tsx
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
-import { ExternalLink, Pencil, RotateCcw, Trash2, Upload, Youtube } from "lucide-react";
+import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { qk } from "../lib/queryKeys";
import { api, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications";
@@ -130,6 +131,7 @@ export function usePlaylistActions() {
// Re-read after the dialog: another menu may have started one while this one asked.
if (!ok || syncing(pl.id)) return;
mark("reverting", pl.id, true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.revertPlaylist(pl.id);
refresh(pl.id);
@@ -148,6 +150,7 @@ export function usePlaylistActions() {
if (syncing(pl.id)) return;
const name = playlistName(pl, t);
mark("pushing", pl.id, true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const plan = await api.playlistPushPlan(pl.id);
const nothingToDo =
@@ -266,7 +269,7 @@ export function usePlaylistActions() {
icon: pushing.has(pl.id) ? (
) : linked ? (
-
+
) : (
),
diff --git a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
index d3e3c96a..8098dfdc 100644
--- a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
+++ b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
@@ -99,13 +99,13 @@ describe("usePlexPlaylistEdits · the reorder", () => {
// answers the same from now on — so the rollback's own refetch comes back deep-equal, keeps
// the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
- act(() =>
+ act(() => {
qc.setQueryData(qk.plexPlaylist(5, "en"), {
id: 5,
title: "F",
items: [movie("1"), movie("3")],
- }),
- );
+ });
+ });
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
// (react-query batches its notification, so without this wait the refresh landed only after the
// rollback and quietly corrected a stale one, which proved nothing.)
diff --git a/frontend/src/components/usePlexPlaylistEdits.ts b/frontend/src/components/usePlexPlaylistEdits.ts
index 1259e83b..0603d8cf 100644
--- a/frontend/src/components/usePlexPlaylistEdits.ts
+++ b/frontend/src/components/usePlexPlaylistEdits.ts
@@ -96,6 +96,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
const before = items.map((i) => i.id);
const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id)));
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
else await api.plexPlaylistRemoveBulk(playlistId, keys);
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
index a15943b3..a4b3b2f0 100644
--- a/frontend/src/design-lab/batchB.tsx
+++ b/frontend/src/design-lab/batchB.tsx
@@ -16,8 +16,8 @@ import {
SlidersHorizontal,
Tv,
Users,
- Youtube,
} from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
@@ -285,7 +285,7 @@ function WatchPage() {
type NavDef = { icon: typeof Home; en: string; hu: string };
const NAV: NavDef[] = [
{ icon: Home, en: "Home", hu: "Kezdőlap" },
- { icon: Youtube, en: "Feed", hu: "Feed" },
+ { icon: YouTubeIcon, en: "Feed", hu: "Feed" },
{ icon: Tv, en: "Channels", hu: "Csatornák" },
{ icon: ListVideo, en: "Playlists", hu: "Listák" },
{ icon: Clapperboard, en: "Plex", hu: "Plex" },
diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json
index ad2d6cd1..ac70e6d0 100644
--- a/frontend/src/i18n/locales/en/player.json
+++ b/frontend/src/i18n/locales/en/player.json
@@ -34,6 +34,7 @@
"playInApp": "Play in the in-app player",
"unavailableTitle": "Can't play this here",
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
+ "apiLoadFailedBody": "The YouTube player didn't load — a connection problem or a content blocker may be stopping it. It still plays on YouTube.",
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json
index 4aadfe84..b150a7c0 100644
--- a/frontend/src/i18n/locales/hu/player.json
+++ b/frontend/src/i18n/locales/hu/player.json
@@ -34,6 +34,7 @@
"playInApp": "Lejátszás a beépített lejátszóban",
"unavailableTitle": "Ez itt nem játszható le",
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
+ "apiLoadFailedBody": "A YouTube-lejátszó nem töltődött be — kapcsolati hiba vagy tartalomblokkoló akadályozhatja. A YouTube-on viszont lejátszható.",
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az automatikusan generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
"openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
diff --git a/frontend/src/lib/brandIcons.ts b/frontend/src/lib/brandIcons.ts
new file mode 100644
index 00000000..59669785
--- /dev/null
+++ b/frontend/src/lib/brandIcons.ts
@@ -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" }],
+]);
diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts
index e559a6ff..ec751155 100644
--- a/frontend/src/lib/clipboard.ts
+++ b/frontend/src/lib/clipboard.ts
@@ -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;
diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts
index d115d6a6..199647b0 100644
--- a/frontend/src/lib/history.ts
+++ b/frontend/src/lib/history.ts
@@ -207,6 +207,9 @@ export function useHistorySubview(
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 () => {
diff --git a/frontend/src/lib/lintProbe.testkit.ts b/frontend/src/lib/lintProbe.testkit.ts
index 4e513bea..849b8f42 100644
--- a/frontend/src/lib/lintProbe.testkit.ts
+++ b/frontend/src/lib/lintProbe.testkit.ts
@@ -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" });
diff --git a/frontend/src/lib/mediaLog.ts b/frontend/src/lib/mediaLog.ts
index ad839847..c97b55b3 100644
--- a/frontend/src/lib/mediaLog.ts
+++ b/frontend/src/lib/mediaLog.ts
@@ -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),
+ });
+}
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index 43aae549..d6147779 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -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",
diff --git a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
new file mode 100644
index 00000000..54ae2e66
--- /dev/null
+++ b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
@@ -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() as RefObject,
+ 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();
+ });
+});
diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
index c27fee77..30bb2c78 100644
--- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
+++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
@@ -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) {
activeId: VID,
resumeAt: 748,
mountRef: { current: mount },
- playerRef: createRef() as MutableRefObject,
+ playerRef: createRef() as RefObject,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts
index b0b15f88..99f3b720 100644
--- a/frontend/src/lib/playerModalHooks.ts
+++ b/frontend/src/lib/playerModalHooks.ts
@@ -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 | null = null;
function loadYouTubeApi(): Promise {
if (apiPromise) return apiPromise;
- apiPromise = new Promise((resolve) => {
+ apiPromise = new Promise((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;
+ playerRef: RefObject;
dialogRef: RefObject;
focusModal: () => void;
}): {
@@ -188,7 +204,7 @@ export function useNativeControlsYield({
stageRef,
focusModal,
}: {
- playerRef: MutableRefObject;
+ playerRef: RefObject;
stageRef: RefObject;
focusModal: () => void;
}): {
@@ -234,13 +250,13 @@ export function useYtBigView({
focusModal,
}: {
stageRef: RefObject;
- playerRef: MutableRefObject;
+ playerRef: RefObject;
focusModal: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
- maximizedRef: MutableRefObject;
- isFullscreenRef: MutableRefObject;
+ maximizedRef: RefObject;
+ isFullscreenRef: RefObject;
toggleFullscreen: () => void;
toggleTrueFullscreen: () => void;
exitBigView: () => void;
@@ -327,10 +343,10 @@ export function useYtProgress({
activeId: string;
resumeAt: number;
mountRef: RefObject;
- playerRef: MutableRefObject;
- currentIdRef: MutableRefObject;
- navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
- modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
+ playerRef: RefObject;
+ currentIdRef: RefObject;
+ 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