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:
2026-10-03 16:34:20 +02:00
parent 3c16012524
commit 471846d784
72 changed files with 938 additions and 216 deletions
+107 -3
View File
@@ -4,6 +4,9 @@ import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import prettier from "eslint-config-prettier"; import prettier from "eslint-config-prettier";
import globals from "globals"; 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 // 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 // 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 // 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). // 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 `-- <the invariant>` 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 // 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 // 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 = 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)."; "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( export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
suppressionRules,
{ {
files: ["src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
globals: { ...globals.browser }, globals: { ...globals.browser },
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
}, },
plugins: { plugins: {
"react-hooks": reactHooks, "react-hooks": reactHooks,
"react-refresh": reactRefresh, "react-refresh": reactRefresh,
local: localRules, local: localRules,
}, },
linterOptions: {
reportUnusedDisableDirectives: "error",
},
rules: { rules: {
...reactHooks.configs.recommended.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 // 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 // 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: // 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. // 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. // …and render throwaway markup whose labels no user ever hears.
files: ["src/**/*.test.{ts,tsx}"], files: ["src/**/*.test.{ts,tsx}"],
plugins: { vitest },
rules: { 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-globals": "off",
"no-restricted-properties": "off", "no-restricted-properties": "off",
"local/literal-a11y-text": "off", "local/literal-a11y-text": "off",
@@ -720,5 +791,38 @@ export default tseslint.config(
"local/glyph-link": "off", "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, prettier,
); );
+299
View File
@@ -24,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.7", "@babel/core": "7.29.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
@@ -32,9 +33,11 @@
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24", "eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0", "globals": "16.5.0",
@@ -970,6 +973,36 @@
"node": ">=12" "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": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "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" "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": { "node_modules/@vitest/expect": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -4087,6 +4357,35 @@
"eslint": ">=7.0.0" "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": { "node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+3
View File
@@ -40,6 +40,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.7", "@babel/core": "7.29.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
@@ -48,9 +49,11 @@
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24", "eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0", "globals": "16.5.0",
+5 -2
View File
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; 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 { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
@@ -117,6 +118,7 @@ function MembershipPanel({
async function toggle(pl: Playlist) { async function toggle(pl: Playlist) {
if (busy) return; if (busy) return;
setBusy(true); 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 { try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId);
@@ -132,6 +134,7 @@ function MembershipPanel({
const name = newName.trim(); const name = newName.trim();
if (!name || busy) return; if (!name || busy) return;
setBusy(true); 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 { try {
const pl = await api.createPlaylist(name); const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId); await api.addToPlaylist(pl.id, videoId);
@@ -183,7 +186,7 @@ function MembershipPanel({
<MenuCheck kind="many" on={!!pl.has_video} /> <MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span> <span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && ( {(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" /> <YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)} )}
</PopoverItem> </PopoverItem>
))} ))}
+10 -5
View File
@@ -23,13 +23,14 @@ import i18n from "../i18n";
const renders: { page: string; lang: string }[] = []; const renders: { page: string; lang: string }[] = [];
let mounts = 0; let mounts = 0;
const countMount = () => {
mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
};
function Probe() { function Probe() {
const { page } = useNavigation(); const { page } = useNavigation();
const { i18n: inst } = useTranslation(); const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language }); renders.push({ page, lang: inst.language });
useEffect(() => { useEffect(countMount, []);
mounts++;
}, []);
return <div data-testid="app">{page}</div>; return <div data-testid="app">{page}</div>;
} }
@@ -226,7 +227,9 @@ describe("BootGate", () => {
const qc = mount(); const qc = mount();
await screen.findByTestId("app"); await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch. // 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))); await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
@@ -241,7 +244,9 @@ describe("BootGate", () => {
vi.stubGlobal("location", { ...window.location, reload }); vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account. // 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))); await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
@@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => {
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false); expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
// The next poll tick: the background job has stored 60 more. // 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)); await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
}); });
@@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("blocked"); expect(result.current.view).toBe("blocked");
// Browser Back to an entry stamped with a different tab. // Browser Back to an entry stamped with a different tab.
act(() => act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })), window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
); });
expect(result.current.view).toBe("discovery"); expect(result.current.view).toBe("discovery");
expect(localStorage.getItem(VIEW_KEY)).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)", () => { it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setView("hidden")); 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 expect(result.current.view).toBe("hidden"); // unchanged
}); });
}); });
+12 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../lib/chatDock"; } from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket"; import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
import { errorAttrs, log } from "../lib/log";
import Avatar from "./ui/Avatar"; import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay"; 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. // Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => { useEffect(() => {
initDock(meId); 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]); }, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and // Live delivery: refresh the conversation list, unread badge, and the affected thread; and
+2 -1
View File
@@ -54,7 +54,8 @@ export default function ChatThread({
useEffect(() => { useEffect(() => {
if (isSystem || !ready) return; if (isSystem || !ready) return;
let cancelled = false; 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<number, string> = {}; const out: Record<number, string> = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and // 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 // once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
+1 -1
View File
@@ -104,7 +104,7 @@ export default function DownloadDialog({
<ProfileEditor <ProfileEditor
onClose={() => { onClose={() => {
setManage(false); setManage(false);
profilesQ.refetch(); void profilesQ.refetch();
}} }}
/> />
</Suspense> </Suspense>
+4 -4
View File
@@ -19,9 +19,9 @@ import {
RefreshCw, RefreshCw,
Rows3, Rows3,
Trash2, Trash2,
Youtube,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api"; import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors"; 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 // 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. // being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
const onStateRef = useRef(onState); 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; onStateRef.current = onState;
const onResetState = useCallback( const onResetState = useCallback(
@@ -473,7 +473,7 @@ export default function Feed({
{t("feed.yt.back")} {t("feed.yt.back")}
</Button> </Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" /> <YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate"> <span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })} {t("feed.yt.resultsFor", { query: ytSearch })}
</span> </span>
@@ -882,7 +882,7 @@ export default function Feed({
{filters.q.trim() && !isDemo && ( {filters.q.trim() && !isDemo && (
<Button <Button
className="mt-4" className="mt-4"
icon={<Youtube />} icon={<YouTubeIcon />}
onClick={() => onYtSearch(filters.q.trim())} onClick={() => onYtSearch(filters.q.trim())}
> >
{t("feed.yt.searchFor", { query: filters.q.trim() })} {t("feed.yt.searchFor", { query: filters.q.trim() })}
+2 -2
View File
@@ -1,6 +1,6 @@
import { forwardRef, type ReactNode } from "react"; import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react"; import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api"; import type { Me } from "../lib/api";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox"; import PlexSearchBox from "./PlexSearchBox";
@@ -61,7 +61,7 @@ const Header = forwardRef<
onGo={() => onYtSearch(trimmed)} onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")} goLabel={t("header.searchYoutube")}
goTitle={t("header.searchYoutubeTip")} goTitle={t("header.searchYoutubeTip")}
goIcon={<Youtube className="w-4 h-4" />} goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed || me.is_demo} goDisabled={!trimmed || me.is_demo}
/> />
); );
+3 -3
View File
@@ -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 { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react"; import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
@@ -606,7 +606,7 @@ function Attachments({
adding: boolean; adding: boolean;
onAdd: (files: Iterable<File>) => void; onAdd: (files: Iterable<File>) => void;
onReview: (source: Source) => void; onReview: (source: Source) => void;
reviewed: MutableRefObject<Source | null>; reviewed: RefObject<Source | null>;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; 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. // A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null); const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({}); const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
// 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(() => { useEffect(() => {
const source = reviewed.current; const source = reviewed.current;
if (!source) return; if (!source) return;
+2
View File
@@ -44,6 +44,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (pass.length < 6) return setErr(t("messages.passTooShort")); if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch")); if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true); 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 { try {
if (configured) { if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!); await e2ee.unlock(meId, pass, keyQ.data!);
@@ -76,6 +77,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
setErr(null); setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass")); if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true); 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 { try {
await api.resetMessageKey(hasPassword ? curPass : undefined); await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears // Server key + dead history are gone; wipe this device's identity too so the gate re-appears
+2 -1
View File
@@ -100,7 +100,8 @@ function ConversationList({
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
let cancelled = false; let cancelled = false;
(async () => { // Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
void (async () => {
const out: Record<number, string> = {}; const out: Record<number, string> = {};
for (const c of conversationsRef.current?.items ?? []) { for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message; const m = c.last_message;
@@ -84,7 +84,9 @@ describe("NavigationProvider · the tab title", () => {
await settle(); await settle();
expect(document.title).toBe("Siftlode"); expect(document.title).toBe("Siftlode");
act(() => qc.setQueryData(qk.me(), { id: 1 })); act(() => {
qc.setQueryData(qk.me(), { id: 1 });
});
await settle(); await settle();
expect(document.title).not.toBe("Siftlode"); expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true); expect(document.title.endsWith(" · Siftlode")).toBe(true);
+4 -3
View File
@@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; 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 { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding"; import { beginGrant, endOnboarding } from "../lib/onboarding";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -186,7 +187,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
{step === "read" && ( {step === "read" && (
<> <>
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent"> <div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" /> <YouTubeIcon className="w-6 h-6" />
</div> </div>
<h2 id={titleId} className="text-lg font-semibold"> <h2 id={titleId} className="text-lg font-semibold">
{t("onboarding.read.title")} {t("onboarding.read.title")}
@@ -199,7 +200,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</p> </p>
<ConsentHeadsUp /> <ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2"> <div className="mt-5 flex flex-col gap-2">
<Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}> <Button size="lg" icon={<YouTubeIcon />} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")} {t("onboarding.read.connect")}
</Button> </Button>
<Button variant="ghost" size="md" onClick={close}> <Button variant="ghost" size="md" onClick={close}>
+7 -3
View File
@@ -70,6 +70,7 @@ import {
useYtBigView, useYtBigView,
useYtProgress, useYtProgress,
useYtVolume, useYtVolume,
YT_API_LOAD_FAILED,
} from "../lib/playerModalHooks"; } from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
@@ -372,6 +373,7 @@ export default function PlayerModal({
const victim = active; const victim = active;
removingRef.current = true; removingRef.current = true;
let done = false; let done = false;
// 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 { try {
done = await onRemoveFromList(victim); done = await onRemoveFromList(victim);
} finally { } finally {
@@ -831,9 +833,11 @@ export default function PlayerModal({
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" /> <AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3> <h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4"> <p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150 {playerError === YT_API_LOAD_FAILED
? t("player.embedDisabledBody") ? t("player.apiLoadFailedBody")
: t("player.unavailableBody")} : playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p> </p>
<LinkButton <LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`} href={`https://www.youtube.com/watch?v=${currentVideoId}`}
+8 -7
View File
@@ -18,7 +18,8 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Check, FolderInput, History, ListPlus, Play, X, Youtube } from "lucide-react"; import { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api"; import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
@@ -460,10 +461,10 @@ export default function Playlists() {
* the X column became the Delete key + this toast; no confirm, because nothing is lost). * the X column became the Delete key + this toast; no confirm, because nothing is lost).
* @returns whether the item was actually removed — the player needs to know, because it must * @returns whether the item was actually removed — the player needs to know, because it must
* step to the next item (or close) only when the removal really happened. */ * step to the next item (or close) only when the removal really happened. */
async function removeItem( async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
video: Video, // Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
listId: number | null = selectedRef.current, // cannot reorder a ref read in a default parameter and skipped the component for it.
): Promise<boolean> { const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null) return false; if (listId == null) return false;
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can // Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
// be removing from the list it was launched from while another one is selected. // be removing from the list it was launched from while another one is selected.
@@ -685,7 +686,7 @@ export default function Playlists() {
label: plName(pl), label: plName(pl),
icon: icon:
pl.source === "youtube" || pl.yt_playlist_id ? ( pl.source === "youtube" || pl.yt_playlist_id ? (
<Youtube className="w-4 h-4" /> <YouTubeIcon className="w-4 h-4" />
) : ( ) : (
<ListPlus className="w-4 h-4" /> <ListPlus className="w-4 h-4" />
), ),
@@ -866,7 +867,7 @@ export default function Playlists() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(e); renameFocus.arm(e);
saveRename(); void saveRename(); // plActions.rename reports its own failure
} }
if (e.key === "Escape") { if (e.key === "Escape") {
renameFocus.arm(e); renameFocus.arm(e);
+4 -2
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { compareText } from "../lib/collate"; import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
@@ -155,6 +156,7 @@ export default function PlaylistsRail({
async function syncYoutube() { async function syncYoutube() {
if (syncing) return; if (syncing) return;
setSyncing(true); setSyncing(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 { try {
const r = await api.syncYoutubePlaylists(); const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -360,7 +362,7 @@ export default function PlaylistsRail({
<div className="flex items-center gap-1 text-[0.8125rem] text-fg"> <div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span> <span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && ( {(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube <YouTubeIcon
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`} className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/> />
)} )}
+2 -2
View File
@@ -201,7 +201,7 @@ export default function PlexBrowse() {
// needs — the navigation handlers record where the page was, and the restore layout effect above // needs — the navigation handlers record where the page was, and the restore layout effect above
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what // reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
// makes it safe, and nothing here reads the value during render. // makes it safe, and nothing here reads the value during render.
// eslint-disable-next-line react-hooks/immutability // eslint-disable-next-line react-hooks/immutability -- a scroll memory: handlers record, the restore layout effect reads, nothing reads it in render
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px); const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu). // One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>(); const menu = useContextMenu<PlexCard>();
@@ -289,7 +289,7 @@ export default function PlexBrowse() {
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState // belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass // on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
// — the guard above is false immediately afterwards. // — the guard above is false immediately afterwards.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
setCommittedLang(lang); setCommittedLang(lang);
if (filters.genres.length) setFilters({ ...filters, genres: [] }); if (filters.genres.length) setFilters({ ...filters, genres: [] });
} }
@@ -71,6 +71,7 @@ export default function PlexCollectionEditor({
if (busy) return; if (busy) return;
setBusy(id); setBusy(id);
const inIt = members.has(id); const inIt = members.has(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 { try {
if (inIt) await api.plexCollectionRemoveItem(id, item.id); if (inIt) await api.plexCollectionRemoveItem(id, item.id);
else await api.plexCollectionAddItem(id, item.id); else await api.plexCollectionAddItem(id, item.id);
@@ -94,6 +95,7 @@ export default function PlexCollectionEditor({
async function markEditable(id: string) { async function markEditable(id: string) {
if (busy) return; if (busy) return;
setBusy(id); setBusy(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 { try {
await api.plexSetCollectionEditable(id, true); await api.plexSetCollectionEditable(id, true);
refresh(); refresh();
@@ -108,6 +110,7 @@ export default function PlexCollectionEditor({
const title = newName.trim(); const title = newName.trim();
if (!title || busy) return; if (!title || busy) return;
setBusy("__new__"); setBusy("__new__");
// 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 { try {
const col = await api.plexCreateCollection(library, title, item.id); const col = await api.plexCreateCollection(library, title, item.id);
setNewName(""); setNewName("");
@@ -126,6 +129,7 @@ export default function PlexCollectionEditor({
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true }))) if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
return; return;
setBusy(id); setBusy(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 { try {
await api.plexDeleteCollection(id); await api.plexDeleteCollection(id);
setMembers((prev) => { setMembers((prev) => {
@@ -148,6 +148,7 @@ export default function PlexCollectionView({
async function playAll(shuffle: boolean) { async function playAll(shuffle: boolean) {
setQueueBusy(true); setQueueBusy(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 { try {
let queue = await buildQueue(); let queue = await buildQueue();
if (shuffle) queue = shuffled(queue); if (shuffle) queue = shuffled(queue);
@@ -206,6 +207,7 @@ export default function PlexCollectionView({
return; return;
} }
setBusy(true); 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 { try {
await api.plexRenameCollection(collectionId, title); await api.plexRenameCollection(collectionId, title);
invalidate(); invalidate();
@@ -238,6 +240,7 @@ export default function PlexCollectionView({
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) }))) if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return; return;
setBusy(true); 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 { try {
await api.plexCollectionRemoveItem(collectionId, card.id); await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate(); invalidate();
@@ -250,6 +253,7 @@ export default function PlexCollectionView({
async function enableEdit() { async function enableEdit() {
setBusy(true); 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 { try {
await api.plexSetCollectionEditable(collectionId, true); await api.plexSetCollectionEditable(collectionId, true);
invalidate(); invalidate();
@@ -302,7 +306,7 @@ export default function PlexCollectionView({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(e); renameFocus.arm(e);
doRename(); void doRename(); // reports its own failure
} }
if (e.key === "Escape") { if (e.key === "Escape") {
renameFocus.arm(e); renameFocus.arm(e);
@@ -219,7 +219,9 @@ describe("a tap on the picture (C3)", () => {
video()._paused = true; video()._paused = true;
act(() => void video().play()); act(() => void video().play());
expect(bandHidden()).toBe(false); expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(3000)); act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
}); });
}); });
@@ -247,10 +249,14 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows
act(() => vol.focus()); act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" }); fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(5000)); act(() => {
vi.advanceTimersByTime(5000);
});
expect(bandHidden()).toBe(false); expect(bandHidden()).toBe(false);
act(() => vol.blur()); act(() => vol.blur());
act(() => vi.advanceTimersByTime(3000)); act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
}); });
@@ -264,9 +270,13 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); fireEvent.keyDown(document.body, { key: "Tab" });
act(() => vol.focus()); act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" }); fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(9000)); act(() => {
vi.advanceTimersByTime(9000);
});
expect(bandHidden()).toBe(false); expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(1500)); act(() => {
vi.advanceTimersByTime(1500);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
expect(document.activeElement).toBe(vol); expect(document.activeElement).toBe(vol);
fireEvent.keyDown(vol, { key: "ArrowUp" }); fireEvent.keyDown(vol, { key: "ArrowUp" });
@@ -283,7 +293,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus()); act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" }); fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500)); act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length; const pauses = pause.mock.calls.length;
// prevented: the unseen Mute is not pressed on keyup // prevented: the unseen Mute is not pressed on keyup
@@ -304,7 +316,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus()); act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" }); fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500)); act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length; const pauses = pause.mock.calls.length;
expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false); expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false);
@@ -325,7 +339,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus()); act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" }); fireEvent.keyDown(mute, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(10_500)); act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
expect(fireEvent.keyDown(mute, { key })).toBe(false); expect(fireEvent.keyDown(mute, { key })).toBe(false);
const toggles = pause.mock.calls.length + play.mock.calls.length; const toggles = pause.mock.calls.length + play.mock.calls.length;
@@ -352,7 +368,9 @@ describe("keyboard focus in the bands", () => {
const skip = screen.getByRole("button", { name: /plex\.player\.skipIntro/ }); const skip = screen.getByRole("button", { name: /plex\.player\.skipIntro/ });
fireEvent.keyDown(document.body, { key: "Tab" }); fireEvent.keyDown(document.body, { key: "Tab" });
act(() => skip.focus()); act(() => skip.focus());
act(() => vi.advanceTimersByTime(10_500)); act(() => {
vi.advanceTimersByTime(10_500);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length; const pauses = pause.mock.calls.length;
// not prevented: the button's own activation runs, and Space does not pause the film instead // not prevented: the button's own activation runs, and Space does not pause the film instead
@@ -377,7 +395,9 @@ describe("keyboard focus in the bands", () => {
return sel === ":focus-visible" ? false : matches.call(this, sel); return sel === ":focus-visible" ? false : matches.call(this, sel);
}); });
act(() => btn.focus()); act(() => btn.focus());
act(() => vi.advanceTimersByTime(3000)); act(() => {
vi.advanceTimersByTime(3000);
});
expect(bandHidden()).toBe(true); expect(bandHidden()).toBe(true);
act(() => btn.blur()); // the tap on the picture takes the focus first… act(() => btn.blur()); // the tap on the picture takes the focus first…
pressPicture("touch"); // …then toggles pressPicture("touch"); // …then toggles
@@ -428,7 +448,9 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 450); // reveal pressPicture("touch", 450); // reveal
expect(bandHidden()).toBe(false); expect(bandHidden()).toBe(false);
expect(band()).toContain("pointer-events-none"); expect(band()).toContain("pointer-events-none");
act(() => vi.advanceTimersByTime(300)); act(() => {
vi.advanceTimersByTime(300);
});
expect(band()).not.toContain("pointer-events-none"); expect(band()).not.toContain("pointer-events-none");
}); });
@@ -270,7 +270,9 @@ describe("the subtitle poll and notice", () => {
it("shows a toast raised while it is open inside itself, not under its scrim", async () => { it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } }); mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A"); await screen.findByText("Title A");
act(() => notify({ level: "error", message: "Couldn't mark it watched." })); act(() => {
notify({ level: "error", message: "Couldn't mark it watched." });
});
const toast = await screen.findByText("Couldn't mark it watched."); const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video(); let host: Element | null = video();
while (host && !host.contains(toast)) host = host.parentElement; while (host && !host.contains(toast)) host = host.parentElement;
+6 -4
View File
@@ -27,7 +27,7 @@ import {
Volume2, Volume2,
VolumeX, VolumeX,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type PlexTrackNaming } from "../lib/api"; import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { import {
audioOrdForLang, audioOrdForLang,
@@ -418,7 +418,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight — // "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below. // it's re-set honestly from the response below.
setSessionIsTranscode(false); setSessionIsTranscode(false);
let sess; // Initialised, not a bare `let sess;`: the compiler's analysis read an unassigned local as a
// value from OUTSIDE this callback and asked for `sess.hw` as a dependency (memo-dependencies).
let sess: PlexPlaySession | null = null;
try { try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is // Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
// selected client-side after the manifest loads. // selected client-side after the manifest loads.
@@ -724,7 +726,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session // sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
// that its response no longer belongs to anything, and a value captured when the effect ran // that its response no longer belongs to anything, and a value captured when the effect ran
// would invalidate a generation that has since been superseded instead of the current one. // would invalidate a generation that has since been superseded instead of the current one.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps -- sessGenRef is a LIVE counter: unmount must bump the current generation, not a copy
sessGenRef.current++; sessGenRef.current++;
if (hlsRef.current) { if (hlsRef.current) {
hlsRef.current.destroy(); hlsRef.current.destroy();
@@ -837,7 +839,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
video.currentTime = rel; // native seek within the loaded region keeps playing on its own video.currentTime = rel; // native seek within the loaded region keeps playing on its own
if (play) video.play().catch(() => {}); if (play) video.play().catch(() => {});
} else { } else {
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
} }
}, },
[loadSession], [loadSession],
+3 -1
View File
@@ -50,7 +50,7 @@ export default function PlexPlaylistAdd({
: api.plexPlaylists(target.ratingKey), : api.plexPlaylists(target.ratingKey),
}); });
const playlists = listQ.data?.playlists ?? []; const playlists = listQ.data?.playlists ?? [];
const refresh = () => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); const refresh = () => void qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
// How many of the target a playlist currently holds (0..size). // How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number { function inCount(p: PlexPlaylist): number {
@@ -68,6 +68,7 @@ export default function PlexPlaylistAdd({
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
const allIn = inCount(p) >= size; const allIn = inCount(p) >= size;
// 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 { try {
if (isGroup) { if (isGroup) {
if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys); if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
@@ -92,6 +93,7 @@ export default function PlexPlaylistAdd({
const title = newName.trim(); const title = newName.trim();
if (!title || busy) return; if (!title || busy) return;
setBusy(true); 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 { try {
if (isGroup) { if (isGroup) {
const pl = await api.plexCreatePlaylist(title); const pl = await api.plexCreatePlaylist(title);
+1 -1
View File
@@ -742,7 +742,7 @@ export default function PlexPlaylistView({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(e); renameFocus.arm(e);
rename(); void rename(); // reports its own failure
} }
// Unmounting the field fires no blur, so Escape really does cancel. // Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") { if (e.key === "Escape") {
+2 -1
View File
@@ -401,7 +401,8 @@ export function PlaylistPicker() {
onChange={(next) => { onChange={(next) => {
const v = next[0]; const v = next[0];
if (!v) return; if (!v) return;
if (v === CREATE_PLAYLIST) createPlaylist(q); if (v === CREATE_PLAYLIST)
void createPlaylist(q); // reports its own failure
else onOpenPlaylist(Number(v.slice(3))); else onOpenPlaylist(Number(v.slice(3)));
}} }}
/> />
@@ -49,7 +49,10 @@ describe("PrefsProvider seed", () => {
); );
expect(seen[0]).toEqual({ perf: true, hints: false }); expect(seen[0]).toEqual({ perf: true, hints: false });
act(() => vi.advanceTimersByTime(1000)); // past the auto-save debounce // past the auto-save debounce
act(() => {
vi.advanceTimersByTime(1000);
});
expect(save).not.toHaveBeenCalled(); expect(save).not.toHaveBeenCalled();
}); });
}); });
+1 -1
View File
@@ -60,7 +60,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const [spec, setSpec] = useState<DownloadSpec>(BLANK); const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() }); const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => { const startNew = () => {
setEditing(null); setEditing(null);
setName(""); setName("");
@@ -201,7 +201,9 @@ describe("SavedViewsProvider — signed-out tab", () => {
}); });
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
// …and it asks as soon as an account signs in. // …and it asks as soon as an account signs in.
act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} })); act(() => {
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
});
await waitFor(() => expect(spy).toHaveBeenCalledWith("feed")); await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
}); });
}); });
@@ -188,8 +188,9 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const displayView = activeView ?? stickyView; const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null; const isDirty = displayView != null && activeView == null;
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) }); const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> { async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
// 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 { try {
await fn(); await fn();
return true; return true;
@@ -74,7 +74,9 @@ afterEach(() => {
const menuRows = () => const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? ""); screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu() { async function openMenu() {
act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }))); act(() => {
fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }));
});
await waitFor(() => await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
); );
+3 -1
View File
@@ -367,6 +367,7 @@ function SignInMethods({ me }: { me: Me }) {
) )
return; return;
setSessBusy(true); setSessBusy(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 { try {
await api.logoutOthers(); await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") }); notify({ level: "success", message: t("settings.account.sessions.done") });
@@ -377,10 +378,11 @@ function SignInMethods({ me }: { me: Me }) {
} }
}; };
const submit = async (e: React.FormEvent) => { const submit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setErr(null); setErr(null);
setBusy(true); 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 { try {
await api.setPassword(next, me.has_password ? current : undefined); await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false); setOpen(false);
+1
View File
@@ -84,6 +84,7 @@ export default function SetupWizard() {
setAttempt((n) => n + 1); setAttempt((n) => n + 1);
setError(""); setError("");
setBusy(true); 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 { try {
if (step === "admin") { if (step === "admin") {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
+1 -1
View File
@@ -240,7 +240,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true); const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null); const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) }); const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const create = useMutation({ const create = useMutation({
mutationFn: () => mutationFn: () =>
+4 -1
View File
@@ -141,7 +141,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const playPause = () => { const playPause = () => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
v.paused ? v.play() : v.pause(); // play() rejects when a pause or a source change interrupts it; the button state follows the
// element's own events, so there is nothing to undo.
if (v.paused) v.play().catch(() => {});
else v.pause();
}; };
// --- cut-list mutations --- // --- cut-list mutations ---
+2
View File
@@ -200,6 +200,7 @@ export default function WatchPage() {
const unlock = async () => { const unlock = async () => {
setUnlocking(true); setUnlocking(true);
setError(null); setError(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 { try {
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, { const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST", method: "POST",
@@ -219,6 +220,7 @@ export default function WatchPage() {
setStatus("gone"); setStatus("gone");
return; return;
} }
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower a throw inside try/catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
setMeta(await r.json()); setMeta(await r.json());
setStatus("ready"); setStatus("ready");
+4 -2
View File
@@ -478,7 +478,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setMode("forgot"); setMode("forgot");
}; };
async function onSubmit(e: React.FormEvent) { async function onSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setAttempt((n) => n + 1); setAttempt((n) => n + 1);
reset(); reset();
@@ -495,6 +495,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return; return;
} }
setBusy(true); 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 { try {
if (mode === "signin") { if (mode === "signin") {
await api.passwordLogin(mail, password); await api.passwordLogin(mail, password);
@@ -538,7 +539,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
} }
} }
async function onDemo(e: React.FormEvent) { async function onDemo(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setDemoError(""); setDemoError("");
const mail = demoEmail.trim().toLowerCase(); const mail = demoEmail.trim().toLowerCase();
@@ -547,6 +548,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return; return;
} }
setDemoBusy(true); setDemoBusy(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 { try {
const r = await api.demoLogin(mail); const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload(); if (r.authenticated) window.location.reload();
@@ -67,8 +67,8 @@ function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* a partial user fixture: `never` stands in for the full type without an `any` */}
<AccountPanel user={u as any} onClose={() => {}} /> <AccountPanel user={u as never} onClose={() => {}} />
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -35,7 +35,7 @@ function useOperatorContact(): string | null {
const [contact, setContact] = useState<string | null>(null); const [contact, setContact] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
fetchOperatorContact().then((c) => alive && setContact(c)); void fetchOperatorContact().then((c) => alive && setContact(c)); // never rejects: null on failure
return () => { return () => {
alive = false; alive = false;
}; };
@@ -112,9 +112,13 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers(); vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />); render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root()); fireEvent.mouseEnter(root());
act(() => vi.advanceTimersByTime(600)); act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => vi.advanceTimersByTime(400)); act(() => {
vi.advanceTimersByTime(400);
});
expect(screen.getByText("A quiet synopsis.")).toBeTruthy(); expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
}); });
@@ -123,7 +127,9 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers(); vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />); render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" })); fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => vi.advanceTimersByTime(2000)); act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); expect(screen.queryByText("A quiet synopsis.")).toBeNull();
}); });
}); });
+7 -3
View File
@@ -225,7 +225,9 @@ export function PlexPersonView({
menuOpen: menu.open && menu.target === c, menuOpen: menu.open && menu.target === c,
}); });
const section = (titleKey: string, cards: PlexCard[]) => // Not `section`: a local named like the <section> it renders confused the React Compiler's scope
// analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
const renderSection = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? ( cards.length > 0 ? (
<section className="mb-5"> <section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2> <h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
@@ -385,8 +387,8 @@ export function PlexPersonView({
<p className="text-sm text-muted">{t("plex.person.empty")}</p> <p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : ( ) : (
<> <>
{section("plex.person.movies", d.movies)} {renderSection("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)} {renderSection("plex.person.shows", d.shows)}
</> </>
)} )}
</div> </div>
@@ -464,6 +466,7 @@ export function PlexShowView({
); );
async function markAll(w: boolean) { async function markAll(w: boolean) {
setBusy(true); 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 { try {
await api.plexShowState(showId, w); await api.plexShowState(showId, w);
} catch (e) { } catch (e) {
@@ -797,6 +800,7 @@ export function PlexSeasonView({
); );
async function markAll(w: boolean) { async function markAll(w: boolean) {
setBusy(true); 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 { try {
await api.plexSeasonState(seasonId, w); await api.plexSeasonState(seasonId, w);
} catch (e) { } catch (e) {
@@ -231,7 +231,7 @@ interface PopoverSurfaceProps {
* take its name from. */ * take its name from. */
ariaLabel?: string; ariaLabel?: string;
focusManaged?: boolean; focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>; initialFocus?: number | React.RefObject<HTMLElement | null>;
/** The surface never receives pointer events — for a passive caption that must not swallow a click /** The surface never receives pointer events — for a passive caption that must not swallow a click
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable * aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */ * content, links) must NOT set this. */
+1 -1
View File
@@ -205,7 +205,7 @@ export default function Toaster({
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks {/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks
or undocks, and remounting is then the correct behaviour, not the hazard the rule or undocks, and remounting is then the correct behaviour, not the hazard the rule
warns about. */} warns about. */}
{/* eslint-disable-next-line react-hooks/static-components */} {/* eslint-disable-next-line react-hooks/static-components -- a one-slot dock registry: the identity changes only on dock/undock, where a remount is right */}
<Docked /> <Docked />
</div> </div>
)} )}
+3 -1
View File
@@ -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 // 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. // only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
let overlaySeq = 0; 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 /** 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. */ * itself, never in a component that can remount while the overlay stays open. */
export function useOverlayOrder(): number { export function useOverlayOrder(): number {
const [order] = useState(() => ++overlaySeq); const [order] = useState(nextOverlaySeq);
return order; return order;
} }
+1 -1
View File
@@ -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 // 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 — // the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component. // 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; moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id)); return t("common.dnd.movedOver", landing(active.id, over.id));
}, },
@@ -106,6 +106,7 @@ export function useDebugRecording() {
if (!session) return; if (!session) return;
hide(true); hide(true);
let image: Blob | null = null; 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 { try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab. // Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
@@ -89,6 +89,7 @@ export function useDownloadActions({
const quickShare = async (job: DownloadJob) => { const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return; if (sharingId !== null) return;
setSharingId(job.id); 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 { try {
await quickShareLink(qc, job, t); await quickShareLink(qc, job, t);
} finally { } finally {
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; 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 { qk } from "../lib/queryKeys";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications"; 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. // Re-read after the dialog: another menu may have started one while this one asked.
if (!ok || syncing(pl.id)) return; if (!ok || syncing(pl.id)) return;
mark("reverting", pl.id, true); 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 { try {
await api.revertPlaylist(pl.id); await api.revertPlaylist(pl.id);
refresh(pl.id); refresh(pl.id);
@@ -148,6 +150,7 @@ export function usePlaylistActions() {
if (syncing(pl.id)) return; if (syncing(pl.id)) return;
const name = playlistName(pl, t); const name = playlistName(pl, t);
mark("pushing", pl.id, true); 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 { try {
const plan = await api.playlistPushPlan(pl.id); const plan = await api.playlistPushPlan(pl.id);
const nothingToDo = const nothingToDo =
@@ -266,7 +269,7 @@ export function usePlaylistActions() {
icon: pushing.has(pl.id) ? ( icon: pushing.has(pl.id) ? (
<Upload className="w-4 h-4 animate-pulse" /> <Upload className="w-4 h-4 animate-pulse" />
) : linked ? ( ) : linked ? (
<Youtube className="w-4 h-4" /> <YouTubeIcon className="w-4 h-4" />
) : ( ) : (
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
), ),
@@ -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 // 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. // the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never); detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
act(() => act(() => {
qc.setQueryData(qk.plexPlaylist(5, "en"), { qc.setQueryData(qk.plexPlaylist(5, "en"), {
id: 5, id: 5,
title: "F", title: "F",
items: [movie("1"), movie("3")], items: [movie("1"), movie("3")],
}), });
); });
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens. // 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 // (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.) // rollback and quietly corrected a stale one, which proved nothing.)
@@ -96,6 +96,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
const before = items.map((i) => i.id); const before = items.map((i) => i.id);
const set = new Set(keys); const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id))); 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 { try {
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!); if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
else await api.plexPlaylistRemoveBulk(playlistId, keys); else await api.plexPlaylistRemoveBulk(playlistId, keys);
+2 -2
View File
@@ -16,8 +16,8 @@ import {
SlidersHorizontal, SlidersHorizontal,
Tv, Tv,
Users, Users,
Youtube,
} from "lucide-react"; } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { Btn } from "./batchA"; import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath"; import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit"; import { Reference, Sample } from "./labKit";
@@ -285,7 +285,7 @@ function WatchPage() {
type NavDef = { icon: typeof Home; en: string; hu: string }; type NavDef = { icon: typeof Home; en: string; hu: string };
const NAV: NavDef[] = [ const NAV: NavDef[] = [
{ icon: Home, en: "Home", hu: "Kezdőlap" }, { 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: Tv, en: "Channels", hu: "Csatornák" },
{ icon: ListVideo, en: "Playlists", hu: "Listák" }, { icon: ListVideo, en: "Playlists", hu: "Listák" },
{ icon: Clapperboard, en: "Plex", hu: "Plex" }, { icon: Clapperboard, en: "Plex", hu: "Plex" },
+1
View File
@@ -34,6 +34,7 @@
"playInApp": "Play in the in-app player", "playInApp": "Play in the in-app player",
"unavailableTitle": "Can't play this here", "unavailableTitle": "Can't play this here",
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.", "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.", "embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube", "openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window", "shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
+1
View File
@@ -34,6 +34,7 @@
"playInApp": "Lejátszás a beépített lejátszóban", "playInApp": "Lejátszás a beépített lejátszóban",
"unavailableTitle": "Ez itt nem játszható le", "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.", "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ó.", "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", "openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak", "shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
+16
View File
@@ -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" }],
]);
+2
View File
@@ -39,6 +39,7 @@ function legacyCopy(text: string): boolean {
ta.style.height = "1px"; ta.style.height = "1px";
ta.style.padding = "0"; ta.style.padding = "0";
ta.style.border = "none"; 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.clip = "rect(0, 0, 0, 0)";
ta.style.userSelect = "text"; ta.style.userSelect = "text";
ta.style.setProperty("-webkit-user-select", "text"); ta.style.setProperty("-webkit-user-select", "text");
@@ -47,6 +48,7 @@ function legacyCopy(text: string): boolean {
ta.focus(); ta.focus();
ta.select(); ta.select();
ta.setSelectionRange(0, text.length); // iOS needs an explicit range 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"); return document.execCommand("copy");
} catch { } catch {
return false; return false;
+6 -3
View File
@@ -207,6 +207,9 @@ export function useHistorySubview<T>(
type Overlay = { token: number; cb: () => void }; type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = []; const overlayStack: Overlay[] = [];
let overlayCounter = 0; 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. // Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0; let historyDepth = 0;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored. // Programmatic history.back() calls still awaiting their popstate — each must be ignored.
@@ -222,7 +225,7 @@ function reconcileHistory(): void {
const want = overlayStack.length; const want = overlayStack.length;
while (historyDepth < want) { while (historyDepth < want) {
historyDepth++; historyDepth++;
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, ""); window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
} }
while (historyDepth > want) { while (historyDepth > want) {
historyDepth--; historyDepth--;
@@ -371,7 +374,7 @@ export function useBackToClose(onClose: () => void): void {
cb.current = onClose; cb.current = onClose;
useEffect(() => { useEffect(() => {
ensureOverlayListener(); ensureOverlayListener();
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() }; const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
overlayStack.push(entry); overlayStack.push(entry);
scheduleReconcile(); scheduleReconcile();
return () => { return () => {
@@ -396,7 +399,7 @@ export function useBackToExit(active: boolean, onExit: () => void): void {
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
ensureOverlayListener(); ensureOverlayListener();
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() }; const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
overlayStack.push(entry); overlayStack.push(entry);
scheduleReconcile(); scheduleReconcile();
return () => { return () => {
+25 -1
View File
@@ -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. // 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)); 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 () => { beforeAll(async () => {
await eslint.lintText("", { filePath: "src/components/__probe.tsx" }); await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
+8
View File
@@ -88,3 +88,11 @@ export function logYouTubeState(state: number, at: number): void {
export function logYouTubeError(code: number): void { export function logYouTubeError(code: number): void {
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code }); 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),
});
}
+2 -2
View File
@@ -13,9 +13,9 @@ import {
SlidersHorizontal, SlidersHorizontal,
Tv, Tv,
Users, Users,
Youtube,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { YouTubeIcon } from "./brandIcons";
import type { Me } from "./api"; import type { Me } from "./api";
import type { Page } from "./urlState"; import type { Page } from "./urlState";
@@ -62,7 +62,7 @@ const MODULES: readonly ModuleDef[] = [
titleKey: "home.title", titleKey: "home.title",
icon: Home, 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", id: "channels",
labelKey: "header.account.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 { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react"; import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { createRef, type MutableRefObject } from "react"; import { createRef, type RefObject } from "react";
import { useYtProgress } from "./playerModalHooks"; import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache"; import { patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
@@ -73,7 +73,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
activeId: VID, activeId: VID,
resumeAt: 748, resumeAt: 748,
mountRef: { current: mount }, mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as MutableRefObject<YTPlayer | null>, playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID }, currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } }, navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } }, modeRef: { current: { autoMode: "off", loopMode: "off" } },
+95 -71
View File
@@ -4,7 +4,6 @@ import {
useRef, useRef,
useState, useState,
type Dispatch, type Dispatch,
type MutableRefObject,
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
} from "react"; } from "react";
@@ -22,7 +21,7 @@ import { api, type Video } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache"; import { patchCachedProgress } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeError, logYouTubeState } from "./mediaLog"; import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes"; import type { AutoMode, LoopMode } from "./playerModes";
// How close to the end (seconds) counts as "finished" → auto-mark watched. // 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; const MAX_CONSECUTIVE_ERRORS = 3;
// --- IFrame Player API loader (singleton) --- // --- 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; let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> { function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise; if (apiPromise) return apiPromise;
apiPromise = new Promise<YTNamespace>((resolve) => { apiPromise = new Promise<YTNamespace>((resolve, reject) => {
const w = window; const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT); 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; const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => { w.onYouTubeIframeAPIReady = () => {
window.clearTimeout(timer);
if (typeof prev === "function") prev(); if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT. // The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!); resolve(w.YT!);
}; };
const tag = document.createElement("script"); tag.onerror = () => fail("failed to load");
tag.src = "https://www.youtube.com/iframe_api"; tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag); document.head.appendChild(tag);
}); });
@@ -67,7 +83,7 @@ export function useYtVolume({
dialogRef, dialogRef,
focusModal, focusModal,
}: { }: {
playerRef: MutableRefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>; dialogRef: RefObject<HTMLDivElement | null>;
focusModal: () => void; focusModal: () => void;
}): { }): {
@@ -188,7 +204,7 @@ export function useNativeControlsYield({
stageRef, stageRef,
focusModal, focusModal,
}: { }: {
playerRef: MutableRefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>; stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void; focusModal: () => void;
}): { }): {
@@ -234,13 +250,13 @@ export function useYtBigView({
focusModal, focusModal,
}: { }: {
stageRef: RefObject<HTMLDivElement | null>; stageRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
focusModal: () => void; focusModal: () => void;
}): { }): {
isFullscreen: boolean; isFullscreen: boolean;
maximized: boolean; maximized: boolean;
maximizedRef: MutableRefObject<boolean>; maximizedRef: RefObject<boolean>;
isFullscreenRef: MutableRefObject<boolean>; isFullscreenRef: RefObject<boolean>;
toggleFullscreen: () => void; toggleFullscreen: () => void;
toggleTrueFullscreen: () => void; toggleTrueFullscreen: () => void;
exitBigView: () => void; exitBigView: () => void;
@@ -327,10 +343,10 @@ export function useYtProgress({
activeId: string; activeId: string;
resumeAt: number; resumeAt: number;
mountRef: RefObject<HTMLDivElement | null>; mountRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
currentIdRef: MutableRefObject<string>; currentIdRef: RefObject<string>;
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>; navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>; modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void; setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void; setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | 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 // 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 // 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. // rewind to the value the frozen launch queue was snapshotted with.
sessionPosRef: MutableRefObject<Map<string, number>>; sessionPosRef: RefObject<Map<string, number>>;
qc: QueryClient; qc: QueryClient;
}): void { }): void {
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
@@ -391,66 +407,74 @@ export function useYtProgress({
} }
}; };
loadYouTubeApi().then((YT) => { loadYouTubeApi()
if (cancelled || !mountRef.current) return; .then((YT) => {
playerRef.current = new YT.Player(mountRef.current, { if (cancelled || !mountRef.current) return;
width: "100%", playerRef.current = new YT.Player(mountRef.current, {
height: "100%", width: "100%",
videoId: id, height: "100%",
playerVars: { videoId: id,
autoplay: 1, playerVars: {
start: resumeAt || undefined, autoplay: 1,
rel: 0, // limit "related" to the same channel (full removal is no longer possible) start: resumeAt || undefined,
enablejsapi: 1, rel: 0, // limit "related" to the same channel (full removal is no longer possible)
origin: window.location.origin, enablejsapi: 1,
playsinline: 1, origin: window.location.origin,
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size). playsinline: 1,
vq: "hd1080", // Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
}, vq: "hd1080",
events: {
onReady: () => {
focusModal();
reapplyStored();
}, },
onStateChange: (e: YTPlayerEvent) => { events: {
const p0 = playerRef.current; onReady: () => {
logYouTubeState( focusModal();
e?.data, reapplyStored();
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0, },
); onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display. const p0 = playerRef.current;
if (e?.data === 1) { logYouTubeState(
errorStreakRef.current = 0; // a successful play breaks any unplayable run e?.data,
const p = playerRef.current; p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; );
if (d) setLiveData({ title: d.title, author: d.author }); // 1 === playing → sync the navigated-to video's title/author for display.
} if (e?.data === 1) {
// 0 === ended → mark watched, then advance to the next queue item if any. errorStreakRef.current = 0; // a successful play breaks any unplayable run
if (e?.data === 0 && currentIdRef.current === id) { const p = playerRef.current;
if (!autoMarkedRef.current) { const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
autoMarkedRef.current = true; if (d) setLiveData({ title: d.title, author: d.author });
setWatched(true);
} }
// 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(); advanceOnEnd();
} },
}, },
onError: (e: YTPlayerEvent) => { });
logYouTubeError(typeof e?.data === "number" ? e.data : -1); })
setPlayerError(typeof e?.data === "number" ? e.data : -1); // The script never arrived (blocked, offline, timed out) or the player could not be built:
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an // an error state with the way out, never an empty stage that waits forever.
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead .catch((err: unknown) => {
// items can't spin. We do NOT mark the broken video watched. if (cancelled) return;
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone logYouTubeApiFailed(err);
const { autoMode: a } = modeRef.current; setPlayerError(YT_API_LOAD_FAILED);
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 session's positions map (mutated in place, never replaced) — held here for the cleanup. // The session's positions map (mutated in place, never replaced) — held here for the cleanup.
const sessionPos = sessionPosRef.current; const sessionPos = sessionPosRef.current;
@@ -477,7 +501,7 @@ export function useYtProgress({
window.removeEventListener("pagehide", onHide); window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next // 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). // 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 // 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, // 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 // 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 // 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. // 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]); }, [activeId]);
} }
@@ -26,7 +26,9 @@ describe("useControlsVisibility", () => {
it("hides 3 s after the last activity while playing", () => { it("hides 3 s after the last activity while playing", () => {
const { result } = setup(); const { result } = setup();
act(() => result.current.wake()); act(() => result.current.wake());
act(() => vi.advanceTimersByTime(3000)); act(() => {
vi.advanceTimersByTime(3000);
});
expect(result.current.uiVisible).toBe(false); expect(result.current.uiVisible).toBe(false);
}); });
@@ -36,16 +38,45 @@ describe("useControlsVisibility", () => {
result.current.wake(); result.current.wake();
result.current.holdOnHover(true); result.current.holdOnHover(true);
}); });
act(() => vi.advanceTimersByTime(10_000)); act(() => {
vi.advanceTimersByTime(10_000);
});
expect(result.current.uiVisible).toBe(true); expect(result.current.uiVisible).toBe(true);
act(() => result.current.holdOnHover(false)); 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 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); 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. // 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", () => { it("toggle hides a shown bar at once and shows a hidden one", () => {
const { result } = setup(); const { result } = setup();
@@ -12,7 +12,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api"; import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog"; import { dismissError, getError } from "./errorDialog";
@@ -49,7 +49,7 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
videoRef: createRef<HTMLVideoElement>(), videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(), hlsRef: createRef<Hls>(),
multiAudioRef: { current: false }, multiAudioRef: { current: false },
audioRef: { current: 0 } as MutableRefObject<number | null>, audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 }, aoffRef: { current: 0 },
absRef: { current: abs }, absRef: { current: abs },
loadStartRef: { current: loadStart }, loadStartRef: { current: loadStart },
@@ -325,7 +325,7 @@ function TrackHarness() {
videoRef: createRef<HTMLVideoElement>(), videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(), hlsRef: createRef<Hls>(),
multiAudioRef: { current: false }, multiAudioRef: { current: false },
audioRef: { current: 0 } as MutableRefObject<number | null>, audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 }, aoffRef: { current: 0 },
absRef: { current: 3600 }, absRef: { current: 3600 },
loadStartRef: { current: 0 }, loadStartRef: { current: 0 },
+27 -24
View File
@@ -3,7 +3,6 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
type MutableRefObject,
type RefObject, type RefObject,
type WheelEvent as ReactWheelEvent, type WheelEvent as ReactWheelEvent,
} from "react"; } from "react";
@@ -113,16 +112,23 @@ export function useControlsVisibility(opts: {
if (hideTimer.current) window.clearTimeout(hideTimer.current); if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
const wokeAt = Date.now(); const wokeAt = Date.now();
const arm = (ms: number) => { // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
hideTimer.current = window.setTimeout(() => { const busy = () => !!videoRef.current?.paused || menuOpenRef.current || hovered.current;
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. // Not a timer that re-arms itself: a self-calling `arm` read to the compiler's analysis as a
if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return; // value from outside this callback (memo-dependencies), and a self-naming function expression
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt); // tripped one of its invariants. The focus hold re-arms at most once — the second timer fires
if (focused.current && held > 0) return arm(held); // when the hold is over, so it has only `busy` left to ask.
setVisible(false); hideTimer.current = window.setTimeout(() => {
}, ms); if (busy()) return;
}; const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
arm(HIDE_MS); if (focused.current && held > 0) {
hideTimer.current = window.setTimeout(() => {
if (!busy()) setVisible(false);
}, held);
return;
}
setVisible(false);
}, HIDE_MS);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]); }, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback( const show = useCallback(
(visible: boolean) => { (visible: boolean) => {
@@ -188,8 +194,8 @@ export function usePlexProgress(opts: {
id: string; id: string;
qc: QueryClient; qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>; absRef: RefObject<number>;
durationRef: MutableRefObject<number>; durationRef: RefObject<number>;
}): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } { }): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } {
const { id, qc, videoRef, absRef, durationRef } = opts; const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {}); 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 * 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 * 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). */ * 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
export function playhead( export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
absRef: MutableRefObject<number>,
loadStartRef: MutableRefObject<number>,
): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current; return absRef.current > 0 ? absRef.current : loadStartRef.current;
} }
@@ -439,8 +442,8 @@ export function useAutoSkip(opts: {
}): { }): {
activeMarker: PlexMarker | undefined; activeMarker: PlexMarker | undefined;
skipProgress: number | null; skipProgress: number | null;
skipProgressRef: MutableRefObject<number | null>; skipProgressRef: RefObject<number | null>;
cancelAutoSkipRef: MutableRefObject<() => void>; cancelAutoSkipRef: RefObject<() => void>;
doSkip: (m: PlexMarker) => void; doSkip: (m: PlexMarker) => void;
} { } {
const { const {
@@ -594,12 +597,12 @@ export function usePlexTracks(opts: {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>; hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>; multiAudioRef: RefObject<boolean>;
audioRef: MutableRefObject<number | null>; audioRef: RefObject<number | null>;
aoffRef: MutableRefObject<number>; aoffRef: RefObject<number>;
absRef: MutableRefObject<number>; absRef: RefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before /** 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. */ * the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: MutableRefObject<number>; loadStartRef: RefObject<number>;
}): { }): {
audioOrd: number | null; audioOrd: number | null;
setAudioOrd: (ord: number | null) => void; setAudioOrd: (ord: number | null) => void;
@@ -677,7 +680,7 @@ export function usePlexTracks(opts: {
} }
} else { } else {
// Single audio (or the session has not answered yet): re-map the track server-side. // 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], [loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
+6 -2
View File
@@ -16,7 +16,9 @@ describe("useDebounced", () => {
}); });
rerender({ v: "ab" }); rerender({ v: "ab" });
expect(result.current).toBe("a"); expect(result.current).toBe("a");
act(() => vi.advanceTimersByTime(300)); act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe("ab"); expect(result.current).toBe("ab");
}); });
@@ -29,7 +31,9 @@ describe("useDebounced", () => {
expect(result.current).toBe("boom"); expect(result.current).toBe("boom");
rerender({ v: "boomx", s: 1 }); rerender({ v: "boomx", s: 1 });
expect(result.current).toBe("boom"); expect(result.current).toBe("boom");
act(() => vi.advanceTimersByTime(300)); act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe("boomx"); expect(result.current).toBe("boomx");
}); });
}); });
+3 -1
View File
@@ -143,7 +143,9 @@ describe("usePref", () => {
// Local state is instant; the server write is deferred. // Local state is instant; the server write is deferred.
expect(result.current[0]).toBe(30); expect(result.current[0]).toBe(30);
expect(savePrefs).not.toHaveBeenCalled(); expect(savePrefs).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(300)); act(() => {
vi.advanceTimersByTime(300);
});
expect(savePrefs).toHaveBeenCalledTimes(1); expect(savePrefs).toHaveBeenCalledTimes(1);
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 }); expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
}); });
+2 -2
View File
@@ -124,10 +124,10 @@ export function usePref<T>(
pendingSave.current = flush; pendingSave.current = flush;
saveTimer.current = window.setTimeout(() => { saveTimer.current = window.setTimeout(() => {
pendingSave.current = null; pendingSave.current = null;
flush(); void flush();
}, debounceMs); }, debounceMs);
} else { } else {
flush(); void flush();
} }
}, },
[qc, serverKey, localKey, debounceMs], [qc, serverKey, localKey, debounceMs],
+8 -5
View File
@@ -24,21 +24,24 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"resolveJsonModule": true, "resolveJsonModule": true,
// The build-tool configs include two hand-written .js files (tailwind/postcss). checkJs runs // The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config
// the type checker over them via their `@type` JSDoc, so a bad tailwind/postcss option is // file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs
// caught by the gate instead of silently mis-building CSS. Was the last hole in C-4.5. // the type checker over it via its `@type` JSDoc, so a bad option is caught by the gate instead
// of silently mis-building CSS. Was the last hole in C-4.5.
"allowJs": true, "allowJs": true,
"checkJs": true "checkJs": true
}, },
// reactPlugin.ts is the ONE React transform both configs import (E27 S1) — it is build tooling, // reactPlugin.ts is the ONE React transform both configs import (E27 S1) — it is build tooling,
// not app source, so it belongs here and not under tsconfig.json's ["src"]. A file imported by a // not app source, so it belongs here and not under tsconfig.json's ["src"]. A file imported by a
// listed one but absent from `include` is TS6307, not a silent pass. // listed one but absent from `include` is TS6307, not a silent pass. playwright.config.ts is
// here as well as in e2e/tsconfig.json because eslint's typed rules (E32) find a file's project
// through tsconfig.json's `references`, which name this project and not the e2e one.
"include": [ "include": [
"vite.config.ts", "vite.config.ts",
"vitest.config.ts", "vitest.config.ts",
"reactPlugin.ts", "reactPlugin.ts",
"devOnlyGuard.ts", "devOnlyGuard.ts",
"tailwind.config.js", "playwright.config.ts",
"postcss.config.js" "postcss.config.js"
] ]
} }