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:
+107
-3
@@ -4,6 +4,9 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
||||
import vitest from "@vitest/eslint-plugin";
|
||||
import playwright from "eslint-plugin-playwright";
|
||||
|
||||
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
|
||||
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
|
||||
@@ -14,6 +17,11 @@ import globals from "globals";
|
||||
//
|
||||
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
|
||||
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
|
||||
//
|
||||
// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
|
||||
// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
|
||||
// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
|
||||
// a `-- <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
|
||||
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
|
||||
@@ -472,25 +480,83 @@ const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
|
||||
const SPLIT_CLASS_MESSAGE =
|
||||
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||||
|
||||
// Applies to EVERY linted file: a suppression says which rule and why. A bare
|
||||
// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
|
||||
// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
|
||||
const suppressionRules = {
|
||||
plugins: { "@eslint-community/eslint-comments": eslintComments },
|
||||
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||
rules: {
|
||||
"@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }],
|
||||
"@eslint-community/eslint-comments/no-unlimited-disable": "error",
|
||||
},
|
||||
};
|
||||
|
||||
// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
|
||||
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
|
||||
// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget
|
||||
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
|
||||
// unless asked to (`throwOnError`), so those calls are known-safe by name.
|
||||
const typedRules = {
|
||||
"@typescript-eslint/no-floating-promises": [
|
||||
"error",
|
||||
{
|
||||
allowForKnownSafeCalls: [
|
||||
{
|
||||
from: "package",
|
||||
package: "@tanstack/query-core",
|
||||
name: [
|
||||
"invalidateQueries",
|
||||
"refetchQueries",
|
||||
"cancelQueries",
|
||||
"resetQueries",
|
||||
"prefetchQuery",
|
||||
"prefetchInfiniteQuery",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-deprecated": "error",
|
||||
};
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||
suppressionRules,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
local: localRules,
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...typedRules,
|
||||
// The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
|
||||
// the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
|
||||
// error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
|
||||
// this shape yet" (31 try statements, each suppressed inline until E32 S3 rewrites them); the
|
||||
// rest had 0 hits when switched on and are tripwires. The full per-function picture is
|
||||
// components/compilerCoverage.contract.test.ts.
|
||||
"react-hooks/todo": "error",
|
||||
"react-hooks/memo-dependencies": "error",
|
||||
"react-hooks/rule-suppression": "error",
|
||||
"react-hooks/invariant": "error",
|
||||
"react-hooks/syntax": "error",
|
||||
"react-hooks/void-use-memo": "error",
|
||||
"react-hooks/component-hook-factories": "error",
|
||||
"react-hooks/capitalized-calls": "error",
|
||||
"react-hooks/hooks": "error",
|
||||
"react-hooks/memoized-effect-dependencies": "error",
|
||||
"react-hooks/exhaustive-effect-dependencies": "error",
|
||||
"react-hooks/no-deriving-state-in-effects": "error",
|
||||
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
||||
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
|
||||
// triaged rather than bulk-suppressed, because the two answers are different:
|
||||
@@ -709,7 +775,12 @@ export default tseslint.config(
|
||||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||||
// …and render throwaway markup whose labels no user ever hears.
|
||||
files: ["src/**/*.test.{ts,tsx}"],
|
||||
plugins: { vitest },
|
||||
rules: {
|
||||
// A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
|
||||
// (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
|
||||
"vitest/no-focused-tests": "error",
|
||||
"vitest/no-disabled-tests": "error",
|
||||
"no-restricted-globals": "off",
|
||||
"no-restricted-properties": "off",
|
||||
"local/literal-a11y-text": "off",
|
||||
@@ -720,5 +791,38 @@ export default tseslint.config(
|
||||
"local/glyph-link": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.node },
|
||||
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
||||
},
|
||||
rules: {
|
||||
...typedRules,
|
||||
// tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict +
|
||||
// noUnusedLocals/Parameters).
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["e2e/**/*.ts"],
|
||||
plugins: { playwright },
|
||||
rules: {
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
// The two plain-JS configs: this file and postcss's.
|
||||
files: ["*.config.js"],
|
||||
extends: [js.configs.recommended],
|
||||
languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } },
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
Generated
+299
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
@@ -32,9 +33,11 @@
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/eslint-plugin": "1.6.27",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.12.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.5.0",
|
||||
@@ -970,6 +973,36 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-plugin-eslint-comments": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz",
|
||||
"integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"ignore": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
|
||||
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -3236,6 +3269,243 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin": {
|
||||
"version": "1.6.27",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz",
|
||||
"integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "^8.58.0",
|
||||
"@typescript-eslint/utils": "^8.58.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "*",
|
||||
"eslint": ">=8.57.0",
|
||||
"typescript": ">=5.0.0",
|
||||
"vitest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
|
||||
"integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.0",
|
||||
"@typescript-eslint/types": "^8.71.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
|
||||
"integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
|
||||
"integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
|
||||
"integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
|
||||
"integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
|
||||
"integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
|
||||
"integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/eslint-plugin/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
@@ -4087,6 +4357,35 @@
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.0.tgz",
|
||||
"integrity": "sha512-7HpSKR/D2oCH4RKRauFskcrnDrHvLQKopW7Nen8lqrFZtx/SB+FgqVaH+zyQd32noZSZy9Lrsa+DsYrVUpn6hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"globals": "^17.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8.40.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-playwright/node_modules/globals": {
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
@@ -48,9 +49,11 @@
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/eslint-plugin": "1.6.27",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.12.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.5.0",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { ListPlus, Plus } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
@@ -117,6 +118,7 @@ function MembershipPanel({
|
||||
async function toggle(pl: Playlist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
@@ -132,6 +134,7 @@ function MembershipPanel({
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
@@ -183,7 +186,7 @@ function MembershipPanel({
|
||||
<MenuCheck kind="many" on={!!pl.has_video} />
|
||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||
{(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>
|
||||
))}
|
||||
|
||||
@@ -23,13 +23,14 @@ import i18n from "../i18n";
|
||||
|
||||
const renders: { page: string; lang: string }[] = [];
|
||||
let mounts = 0;
|
||||
const countMount = () => {
|
||||
mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
|
||||
};
|
||||
function Probe() {
|
||||
const { page } = useNavigation();
|
||||
const { i18n: inst } = useTranslation();
|
||||
renders.push({ page, lang: inst.language });
|
||||
useEffect(() => {
|
||||
mounts++;
|
||||
}, []);
|
||||
useEffect(countMount, []);
|
||||
return <div data-testid="app">{page}</div>;
|
||||
}
|
||||
|
||||
@@ -226,7 +227,9 @@ describe("BootGate", () => {
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
|
||||
act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
|
||||
});
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
@@ -241,7 +244,9 @@ describe("BootGate", () => {
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
|
||||
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
|
||||
act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
|
||||
});
|
||||
await act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
@@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => {
|
||||
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
|
||||
|
||||
// The next poll tick: the background job has stored 60 more.
|
||||
act(() => qc.setQueryData(qk.channel(CH), reading(79)));
|
||||
act(() => {
|
||||
qc.setQueryData(qk.channel(CH), reading(79));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
|
||||
});
|
||||
|
||||
@@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => {
|
||||
expect(result.current.view).toBe("blocked");
|
||||
|
||||
// Browser Back to an entry stamped with a different tab.
|
||||
act(() =>
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
|
||||
);
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
|
||||
});
|
||||
|
||||
expect(result.current.view).toBe("discovery");
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
|
||||
@@ -71,7 +71,9 @@ describe("ChannelsProvider tab history", () => {
|
||||
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("hidden"));
|
||||
act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
|
||||
});
|
||||
expect(result.current.view).toBe("hidden"); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../lib/chatDock";
|
||||
import { onMessage, onUnread } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./ui/Overlay";
|
||||
@@ -30,7 +31,17 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
|
||||
useEffect(() => {
|
||||
initDock(meId);
|
||||
e2ee.loadFromDevice(meId);
|
||||
// The device key store can be unopenable (storage disabled, some private windows): messages then
|
||||
// stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
|
||||
e2ee
|
||||
.loadFromDevice(meId)
|
||||
.catch((err: unknown) =>
|
||||
log.warn(
|
||||
"ui.e2ee_device_key_unreadable",
|
||||
"the device key store could not be read",
|
||||
errorAttrs(err),
|
||||
),
|
||||
);
|
||||
}, [meId]);
|
||||
|
||||
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
|
||||
|
||||
@@ -54,7 +54,8 @@ export default function ChatThread({
|
||||
useEffect(() => {
|
||||
if (isSystem || !ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
// 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
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function DownloadDialog({
|
||||
<ProfileEditor
|
||||
onClose={() => {
|
||||
setManage(false);
|
||||
profilesQ.refetch();
|
||||
void profilesQ.refetch();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Trash2,
|
||||
Youtube,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
@@ -352,7 +352,7 @@ export default function Feed({
|
||||
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
|
||||
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
|
||||
const onStateRef = useRef(onState);
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
// eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
|
||||
onStateRef.current = onState;
|
||||
|
||||
const onResetState = useCallback(
|
||||
@@ -473,7 +473,7 @@ export default function Feed({
|
||||
{t("feed.yt.back")}
|
||||
</Button>
|
||||
<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">
|
||||
{t("feed.yt.resultsFor", { query: ytSearch })}
|
||||
</span>
|
||||
@@ -882,7 +882,7 @@ export default function Feed({
|
||||
{filters.q.trim() && !isDemo && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<Youtube />}
|
||||
icon={<YouTubeIcon />}
|
||||
onClick={() => onYtSearch(filters.q.trim())}
|
||||
>
|
||||
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Youtube } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import type { Me } from "../lib/api";
|
||||
import SearchBar from "./SearchBar";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
@@ -61,7 +61,7 @@ const Header = forwardRef<
|
||||
onGo={() => onYtSearch(trimmed)}
|
||||
goLabel={t("header.searchYoutube")}
|
||||
goTitle={t("header.searchYoutubeTip")}
|
||||
goIcon={<Youtube className="w-4 h-4" />}
|
||||
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
||||
goDisabled={!trimmed || me.is_demo}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
@@ -606,7 +606,7 @@ function Attachments({
|
||||
adding: boolean;
|
||||
onAdd: (files: Iterable<File>) => void;
|
||||
onReview: (source: Source) => void;
|
||||
reviewed: MutableRefObject<Source | null>;
|
||||
reviewed: RefObject<Source | null>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -615,7 +615,7 @@ function Attachments({
|
||||
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
||||
const pick = useRef<HTMLInputElement>(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(() => {
|
||||
const source = reviewed.current;
|
||||
if (!source) return;
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
if (pass.length < 6) return setErr(t("messages.passTooShort"));
|
||||
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (configured) {
|
||||
await e2ee.unlock(meId, pass, keyQ.data!);
|
||||
@@ -76,6 +77,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
setErr(null);
|
||||
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.resetMessageKey(hasPassword ? curPass : undefined);
|
||||
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
|
||||
|
||||
@@ -100,7 +100,8 @@ function ConversationList({
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of conversationsRef.current?.items ?? []) {
|
||||
const m = c.last_message;
|
||||
|
||||
@@ -84,7 +84,9 @@ describe("NavigationProvider · the tab title", () => {
|
||||
await settle();
|
||||
expect(document.title).toBe("Siftlode");
|
||||
|
||||
act(() => qc.setQueryData(qk.me(), { id: 1 }));
|
||||
act(() => {
|
||||
qc.setQueryData(qk.me(), { id: 1 });
|
||||
});
|
||||
await settle();
|
||||
expect(document.title).not.toBe("Siftlode");
|
||||
expect(document.title.endsWith(" · Siftlode")).toBe(true);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -186,7 +187,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
{step === "read" && (
|
||||
<>
|
||||
<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>
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{t("onboarding.read.title")}
|
||||
@@ -199,7 +200,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<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")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="md" onClick={close}>
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
useYtBigView,
|
||||
useYtProgress,
|
||||
useYtVolume,
|
||||
YT_API_LOAD_FAILED,
|
||||
} from "../lib/playerModalHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -372,6 +373,7 @@ export default function PlayerModal({
|
||||
const victim = active;
|
||||
removingRef.current = true;
|
||||
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 {
|
||||
done = await onRemoveFromList(victim);
|
||||
} finally {
|
||||
@@ -831,9 +833,11 @@ export default function PlayerModal({
|
||||
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
|
||||
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
|
||||
<p className="text-sm text-muted mt-1.5 mb-4">
|
||||
{playerError === 101 || playerError === 150
|
||||
? t("player.embedDisabledBody")
|
||||
: t("player.unavailableBody")}
|
||||
{playerError === YT_API_LOAD_FAILED
|
||||
? t("player.apiLoadFailedBody")
|
||||
: playerError === 101 || playerError === 150
|
||||
? t("player.embedDisabledBody")
|
||||
: t("player.unavailableBody")}
|
||||
</p>
|
||||
<LinkButton
|
||||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
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 { notify } from "../lib/notifications";
|
||||
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).
|
||||
* @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. */
|
||||
async function removeItem(
|
||||
video: Video,
|
||||
listId: number | null = selectedRef.current,
|
||||
): Promise<boolean> {
|
||||
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
|
||||
// Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
|
||||
// cannot reorder a ref read in a default parameter and skipped the component for it.
|
||||
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
|
||||
if (listId == null) return false;
|
||||
// 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.
|
||||
@@ -685,7 +686,7 @@ export default function Playlists() {
|
||||
label: plName(pl),
|
||||
icon:
|
||||
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" />
|
||||
),
|
||||
@@ -866,7 +867,7 @@ export default function Playlists() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
saveRename();
|
||||
void saveRename(); // plActions.rename reports its own failure
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
renameFocus.arm(e);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { compareText } from "../lib/collate";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
@@ -155,6 +156,7 @@ export default function PlaylistsRail({
|
||||
async function syncYoutube() {
|
||||
if (syncing) return;
|
||||
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 {
|
||||
const r = await api.syncYoutubePlaylists();
|
||||
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">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
<YouTubeIcon
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function PlexBrowse() {
|
||||
// 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
|
||||
// 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);
|
||||
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
|
||||
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
|
||||
// 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.
|
||||
// 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);
|
||||
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function PlexCollectionEditor({
|
||||
if (busy) return;
|
||||
setBusy(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 {
|
||||
if (inIt) await api.plexCollectionRemoveItem(id, item.id);
|
||||
else await api.plexCollectionAddItem(id, item.id);
|
||||
@@ -94,6 +95,7 @@ export default function PlexCollectionEditor({
|
||||
async function markEditable(id: string) {
|
||||
if (busy) return;
|
||||
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 {
|
||||
await api.plexSetCollectionEditable(id, true);
|
||||
refresh();
|
||||
@@ -108,6 +110,7 @@ export default function PlexCollectionEditor({
|
||||
const title = newName.trim();
|
||||
if (!title || busy) return;
|
||||
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 {
|
||||
const col = await api.plexCreateCollection(library, title, item.id);
|
||||
setNewName("");
|
||||
@@ -126,6 +129,7 @@ export default function PlexCollectionEditor({
|
||||
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
|
||||
return;
|
||||
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 {
|
||||
await api.plexDeleteCollection(id);
|
||||
setMembers((prev) => {
|
||||
|
||||
@@ -148,6 +148,7 @@ export default function PlexCollectionView({
|
||||
|
||||
async function playAll(shuffle: boolean) {
|
||||
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 {
|
||||
let queue = await buildQueue();
|
||||
if (shuffle) queue = shuffled(queue);
|
||||
@@ -206,6 +207,7 @@ export default function PlexCollectionView({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.plexRenameCollection(collectionId, title);
|
||||
invalidate();
|
||||
@@ -238,6 +240,7 @@ export default function PlexCollectionView({
|
||||
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
|
||||
return;
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.plexCollectionRemoveItem(collectionId, card.id);
|
||||
invalidate();
|
||||
@@ -250,6 +253,7 @@ export default function PlexCollectionView({
|
||||
|
||||
async function enableEdit() {
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.plexSetCollectionEditable(collectionId, true);
|
||||
invalidate();
|
||||
@@ -302,7 +306,7 @@ export default function PlexCollectionView({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
doRename();
|
||||
void doRename(); // reports its own failure
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
renameFocus.arm(e);
|
||||
|
||||
@@ -219,7 +219,9 @@ describe("a tap on the picture (C3)", () => {
|
||||
video()._paused = true;
|
||||
act(() => void video().play());
|
||||
expect(bandHidden()).toBe(false);
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
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
|
||||
act(() => vol.focus());
|
||||
fireEvent.keyDown(vol, { key: "ArrowUp" });
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(bandHidden()).toBe(false);
|
||||
act(() => vol.blur());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -264,9 +270,13 @@ describe("keyboard focus in the bands", () => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
act(() => vol.focus());
|
||||
fireEvent.keyDown(vol, { key: "ArrowUp" });
|
||||
act(() => vi.advanceTimersByTime(9000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(9000);
|
||||
});
|
||||
expect(bandHidden()).toBe(false);
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
expect(document.activeElement).toBe(vol);
|
||||
fireEvent.keyDown(vol, { key: "ArrowUp" });
|
||||
@@ -283,7 +293,9 @@ describe("keyboard focus in the bands", () => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
act(() => mute.focus());
|
||||
fireEvent.keyDown(mute, { key: "ArrowUp" });
|
||||
act(() => vi.advanceTimersByTime(10_500));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_500);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
const pauses = pause.mock.calls.length;
|
||||
// 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" });
|
||||
act(() => mute.focus());
|
||||
fireEvent.keyDown(mute, { key: "ArrowUp" });
|
||||
act(() => vi.advanceTimersByTime(10_500));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_500);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
const pauses = pause.mock.calls.length;
|
||||
expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false);
|
||||
@@ -325,7 +339,9 @@ describe("keyboard focus in the bands", () => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
act(() => mute.focus());
|
||||
fireEvent.keyDown(mute, { key: "ArrowUp" });
|
||||
act(() => vi.advanceTimersByTime(10_500));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_500);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
expect(fireEvent.keyDown(mute, { key })).toBe(false);
|
||||
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/ });
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
act(() => skip.focus());
|
||||
act(() => vi.advanceTimersByTime(10_500));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_500);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
const pauses = pause.mock.calls.length;
|
||||
// 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);
|
||||
});
|
||||
act(() => btn.focus());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(bandHidden()).toBe(true);
|
||||
act(() => btn.blur()); // the tap on the picture takes the focus first…
|
||||
pressPicture("touch"); // …then toggles
|
||||
@@ -428,7 +448,9 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
|
||||
pressPicture("touch", 450); // reveal
|
||||
expect(bandHidden()).toBe(false);
|
||||
expect(band()).toContain("pointer-events-none");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
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 () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
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.");
|
||||
let host: Element | null = video();
|
||||
while (host && !host.contains(toast)) host = host.parentElement;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} 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 {
|
||||
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 —
|
||||
// it's re-set honestly from the response below.
|
||||
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 {
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
// 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
|
||||
// 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.
|
||||
// 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++;
|
||||
if (hlsRef.current) {
|
||||
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
|
||||
if (play) video.play().catch(() => {});
|
||||
} 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],
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PlexPlaylistAdd({
|
||||
: api.plexPlaylists(target.ratingKey),
|
||||
});
|
||||
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).
|
||||
function inCount(p: PlexPlaylist): number {
|
||||
@@ -68,6 +68,7 @@ export default function PlexPlaylistAdd({
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
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 {
|
||||
if (isGroup) {
|
||||
if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
|
||||
@@ -92,6 +93,7 @@ export default function PlexPlaylistAdd({
|
||||
const title = newName.trim();
|
||||
if (!title || busy) return;
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (isGroup) {
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
|
||||
@@ -742,7 +742,7 @@ export default function PlexPlaylistView({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
rename();
|
||||
void rename(); // reports its own failure
|
||||
}
|
||||
// Unmounting the field fires no blur, so Escape really does cancel.
|
||||
if (e.key === "Escape") {
|
||||
|
||||
@@ -401,7 +401,8 @@ export function PlaylistPicker() {
|
||||
onChange={(next) => {
|
||||
const v = next[0];
|
||||
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)));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,10 @@ describe("PrefsProvider seed", () => {
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
|
||||
const startNew = () => {
|
||||
setEditing(null);
|
||||
setName("");
|
||||
|
||||
@@ -201,7 +201,9 @@ describe("SavedViewsProvider — signed-out tab", () => {
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// …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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,8 +188,9 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const displayView = activeView ?? stickyView;
|
||||
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> {
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await fn();
|
||||
return true;
|
||||
|
||||
@@ -74,7 +74,9 @@ afterEach(() => {
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
async function openMenu() {
|
||||
act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" })));
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
@@ -367,6 +367,7 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
)
|
||||
return;
|
||||
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 {
|
||||
await api.logoutOthers();
|
||||
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();
|
||||
setErr(null);
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.setPassword(next, me.has_password ? current : undefined);
|
||||
setOpen(false);
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function SetupWizard() {
|
||||
setAttempt((n) => n + 1);
|
||||
setError("");
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (step === "admin") {
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
||||
|
||||
@@ -240,7 +240,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
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({
|
||||
mutationFn: () =>
|
||||
|
||||
@@ -141,7 +141,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const playPause = () => {
|
||||
const v = videoRef.current;
|
||||
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 ---
|
||||
|
||||
@@ -200,6 +200,7 @@ export default function WatchPage() {
|
||||
const unlock = async () => {
|
||||
setUnlocking(true);
|
||||
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 {
|
||||
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
|
||||
method: "POST",
|
||||
@@ -219,6 +220,7 @@ export default function WatchPage() {
|
||||
setStatus("gone");
|
||||
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}`);
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
|
||||
@@ -478,7 +478,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
setMode("forgot");
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
async function onSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setAttempt((n) => n + 1);
|
||||
reset();
|
||||
@@ -495,6 +495,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (mode === "signin") {
|
||||
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();
|
||||
setDemoError("");
|
||||
const mail = demoEmail.trim().toLowerCase();
|
||||
@@ -547,6 +548,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const r = await api.demoLogin(mail);
|
||||
if (r.authenticated) window.location.reload();
|
||||
|
||||
@@ -67,8 +67,8 @@ function mount(u: typeof user = user) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<AccountPanel user={u as any} onClose={() => {}} />
|
||||
{/* a partial user fixture: `never` stands in for the full type without an `any` */}
|
||||
<AccountPanel user={u as never} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function useOperatorContact(): string | null {
|
||||
const [contact, setContact] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchOperatorContact().then((c) => alive && setContact(c));
|
||||
void fetchOperatorContact().then((c) => alive && setContact(c)); // never rejects: null on failure
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
|
||||
@@ -112,9 +112,13 @@ describe("PlexPosterCard preview", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||
fireEvent.mouseEnter(root());
|
||||
act(() => vi.advanceTimersByTime(600));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
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.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
|
||||
});
|
||||
@@ -123,7 +127,9 @@ describe("PlexPosterCard preview", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,7 +225,9 @@ export function PlexPersonView({
|
||||
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 ? (
|
||||
<section className="mb-5">
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{section("plex.person.movies", d.movies)}
|
||||
{section("plex.person.shows", d.shows)}
|
||||
{renderSection("plex.person.movies", d.movies)}
|
||||
{renderSection("plex.person.shows", d.shows)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -464,6 +466,7 @@ export function PlexShowView({
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
@@ -797,6 +800,7 @@ export function PlexSeasonView({
|
||||
);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
|
||||
@@ -231,7 +231,7 @@ interface PopoverSurfaceProps {
|
||||
* take its name from. */
|
||||
ariaLabel?: string;
|
||||
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
|
||||
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
|
||||
* content, links) must NOT set this. */
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function Toaster({
|
||||
{/* 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
|
||||
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 />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useState } from "react";
|
||||
// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports
|
||||
// only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
|
||||
let overlaySeq = 0;
|
||||
// Outside the hook: the React Compiler cannot lower `++` on a module variable inside one (react-hooks/todo).
|
||||
const nextOverlaySeq = () => ++overlaySeq;
|
||||
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
|
||||
* itself, never in a component that can remount while the overlay stays open. */
|
||||
export function useOverlayOrder(): number {
|
||||
const [order] = useState(() => ++overlaySeq);
|
||||
const [order] = useState(nextOverlaySeq);
|
||||
return order;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
||||
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
|
||||
// the only thing separating that from a real move. A ref would be the wrong lifetime —
|
||||
// one drag, not the component.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
// eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
|
||||
moved = true;
|
||||
return t("common.dnd.movedOver", landing(active.id, over.id));
|
||||
},
|
||||
|
||||
@@ -106,6 +106,7 @@ export function useDebugRecording() {
|
||||
if (!session) return;
|
||||
hide(true);
|
||||
let image: Blob | null = null;
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
|
||||
@@ -89,6 +89,7 @@ export function useDownloadActions({
|
||||
const quickShare = async (job: DownloadJob) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(job.id);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await quickShareLink(qc, job, t);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Pencil, RotateCcw, Trash2, Upload, Youtube } from "lucide-react";
|
||||
import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -130,6 +131,7 @@ export function usePlaylistActions() {
|
||||
// Re-read after the dialog: another menu may have started one while this one asked.
|
||||
if (!ok || syncing(pl.id)) return;
|
||||
mark("reverting", pl.id, true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
await api.revertPlaylist(pl.id);
|
||||
refresh(pl.id);
|
||||
@@ -148,6 +150,7 @@ export function usePlaylistActions() {
|
||||
if (syncing(pl.id)) return;
|
||||
const name = playlistName(pl, t);
|
||||
mark("pushing", pl.id, true);
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
const plan = await api.playlistPushPlan(pl.id);
|
||||
const nothingToDo =
|
||||
@@ -266,7 +269,7 @@ export function usePlaylistActions() {
|
||||
icon: pushing.has(pl.id) ? (
|
||||
<Upload className="w-4 h-4 animate-pulse" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-4 h-4" />
|
||||
<YouTubeIcon 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
|
||||
// the same `q.data`, and could never correct a stale rollback.
|
||||
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
|
||||
act(() =>
|
||||
act(() => {
|
||||
qc.setQueryData(qk.plexPlaylist(5, "en"), {
|
||||
id: 5,
|
||||
title: "F",
|
||||
items: [movie("1"), movie("3")],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
|
||||
// (react-query batches its notification, so without this wait the refresh landed only after the
|
||||
// rollback and quietly corrected a stale one, which proved nothing.)
|
||||
|
||||
@@ -96,6 +96,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
const before = items.map((i) => i.id);
|
||||
const set = new Set(keys);
|
||||
setItems((prev) => prev.filter((i) => !set.has(i.id)));
|
||||
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
||||
try {
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "../lib/colorMath";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
@@ -285,7 +285,7 @@ function WatchPage() {
|
||||
type NavDef = { icon: typeof Home; en: string; hu: string };
|
||||
const NAV: NavDef[] = [
|
||||
{ icon: Home, en: "Home", hu: "Kezdőlap" },
|
||||
{ icon: Youtube, en: "Feed", hu: "Feed" },
|
||||
{ icon: YouTubeIcon, en: "Feed", hu: "Feed" },
|
||||
{ icon: Tv, en: "Channels", hu: "Csatornák" },
|
||||
{ icon: ListVideo, en: "Playlists", hu: "Listák" },
|
||||
{ icon: Clapperboard, en: "Plex", hu: "Plex" },
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"playInApp": "Play in the in-app player",
|
||||
"unavailableTitle": "Can't play this here",
|
||||
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
|
||||
"apiLoadFailedBody": "The YouTube player didn't load — a connection problem or a content blocker may be stopping it. It still plays on YouTube.",
|
||||
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
|
||||
"openOnYoutube": "Open on YouTube",
|
||||
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"playInApp": "Lejátszás a beépített lejátszóban",
|
||||
"unavailableTitle": "Ez itt nem játszható le",
|
||||
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
|
||||
"apiLoadFailedBody": "A YouTube-lejátszó nem töltődött be — kapcsolati hiba vagy tartalomblokkoló akadályozhatja. A YouTube-on viszont lejátszható.",
|
||||
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az automatikusan generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
|
||||
"openOnYoutube": "Megnyitás YouTube-on",
|
||||
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createLucideIcon } from "lucide-react";
|
||||
|
||||
// Brand marks the app draws itself. Lucide deprecated its brand icons and will remove them, so the
|
||||
// YouTube glyph is held here instead — the same 24×24 stroke paths as lucide's `Youtube` (v0.408,
|
||||
// ISC licence, © Lucide contributors), built with lucide's own factory so it takes the same props
|
||||
// (`className`, `size`, `strokeWidth`) and renders identically next to every other icon.
|
||||
export const YouTubeIcon = createLucideIcon("YouTube", [
|
||||
[
|
||||
"path",
|
||||
{
|
||||
d: "M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",
|
||||
key: "1q2vi4",
|
||||
},
|
||||
],
|
||||
["path", { d: "m10 15 5-3-5-3z", key: "1jp15x" }],
|
||||
]);
|
||||
@@ -39,6 +39,7 @@ function legacyCopy(text: string): boolean {
|
||||
ta.style.height = "1px";
|
||||
ta.style.padding = "0";
|
||||
ta.style.border = "none";
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the iOS-tested hidden-but-selectable textarea (see the header); not re-styled blind
|
||||
ta.style.clip = "rect(0, 0, 0, 0)";
|
||||
ta.style.userSelect = "text";
|
||||
ta.style.setProperty("-webkit-user-select", "text");
|
||||
@@ -47,6 +48,7 @@ function legacyCopy(text: string): boolean {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the only SYNCHRONOUS copy: iOS Safari needs it inside the click gesture, and a non-secure origin has no navigator.clipboard
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -207,6 +207,9 @@ export function useHistorySubview<T>(
|
||||
type Overlay = { token: number; cb: () => void };
|
||||
const overlayStack: Overlay[] = [];
|
||||
let overlayCounter = 0;
|
||||
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
|
||||
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
|
||||
const nextOverlayToken = () => ++overlayCounter;
|
||||
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||||
let historyDepth = 0;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
@@ -222,7 +225,7 @@ function reconcileHistory(): void {
|
||||
const want = overlayStack.length;
|
||||
while (historyDepth < want) {
|
||||
historyDepth++;
|
||||
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
@@ -371,7 +374,7 @@ export function useBackToClose(onClose: () => void): void {
|
||||
cb.current = onClose;
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
@@ -396,7 +399,7 @@ export function useBackToExit(active: boolean, onExit: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
|
||||
@@ -9,7 +9,31 @@ import { beforeAll } from "vitest";
|
||||
// hook below pays that cost once per test file, under its own budget; importing this registers it.
|
||||
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||
|
||||
export const eslint = new ESLint({ cwd: FRONTEND });
|
||||
// The probes lint TEXT under file names that do not exist (`__probe.tsx`, `x.ts`, a feature-folder
|
||||
// `Button.tsx`). Since E32 the src config runs type-aware rules, whose project service refuses a
|
||||
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
|
||||
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
||||
const PROBE_FILES = [
|
||||
"src/*/__*probe.ts",
|
||||
"src/*/__*probe.tsx",
|
||||
"src/components/ui/__*probe.tsx",
|
||||
"src/lib/api/__*probe.ts",
|
||||
"src/components/Button.tsx",
|
||||
"src/lib/x.ts",
|
||||
];
|
||||
|
||||
export const eslint = new ESLint({
|
||||
cwd: FRONTEND,
|
||||
overrideConfig: {
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: { allowDefaultProject: PROBE_FILES, defaultProject: "tsconfig.json" },
|
||||
tsconfigRootDir: FRONTEND,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
|
||||
|
||||
@@ -88,3 +88,11 @@ export function logYouTubeState(state: number, at: number): void {
|
||||
export function logYouTubeError(code: number): void {
|
||||
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code });
|
||||
}
|
||||
|
||||
/** The IFrame API script never arrived (blocked, offline, timed out), so there is no player at all. */
|
||||
export function logYouTubeApiFailed(err: unknown): void {
|
||||
log.warn("player.yt_api_failed", "the YouTube player could not be loaded", {
|
||||
player: "youtube",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "./brandIcons";
|
||||
import type { Me } from "./api";
|
||||
import type { Page } from "./urlState";
|
||||
|
||||
@@ -62,7 +62,7 @@ const MODULES: readonly ModuleDef[] = [
|
||||
titleKey: "home.title",
|
||||
icon: Home,
|
||||
},
|
||||
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: Youtube },
|
||||
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: YouTubeIcon },
|
||||
{
|
||||
id: "channels",
|
||||
labelKey: "header.account.channels",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The YouTube IFrame API script failing to load must END in an error state. `loadYouTubeApi` used to
|
||||
// return a promise that only ever resolved: a blocked or failed script (a content blocker, no
|
||||
// network) left the modal on an empty stage forever, with no error and no way to retry short of a
|
||||
// reload — the promise was memoised, so every later open waited on the same dead one (E32 audit).
|
||||
//
|
||||
// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
|
||||
// and these tests need a module instance that has never seen `window.YT`.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { useYtProgress, YT_API_LOAD_FAILED, YT_API_TIMEOUT_MS } from "./playerModalHooks";
|
||||
import type { YTPlayer } from "./youtube";
|
||||
|
||||
const VID = "EC5oFYy9z9g";
|
||||
|
||||
const apiScripts = () =>
|
||||
[...document.head.querySelectorAll("script")].filter((s) => s.src.includes("iframe_api"));
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function mountPlayer(setPlayerError: (code: number | null) => void) {
|
||||
const mount = document.createElement("div");
|
||||
document.body.appendChild(mount);
|
||||
return renderHook(() =>
|
||||
useYtProgress({
|
||||
activeId: VID,
|
||||
resumeAt: 0,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
navRef: { current: { queue: undefined, index: 0 } },
|
||||
modeRef: { current: { autoMode: "off", loopMode: "off" } },
|
||||
setWatched: () => {},
|
||||
setPlayerError,
|
||||
setLiveData: () => {},
|
||||
reapplyStored: () => {},
|
||||
focusModal: () => {},
|
||||
advanceOnEnd: () => {},
|
||||
sessionPosRef: { current: new Map() },
|
||||
qc: new QueryClient(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useYtProgress — the IFrame API failing to load", () => {
|
||||
// Fail any load still in flight, so the module's memoised promise is cleared for the next test
|
||||
// (a pending one would carry a timer from before that test's fake clock).
|
||||
afterEach(async () => {
|
||||
for (const s of apiScripts()) s.dispatchEvent(new Event("error"));
|
||||
await settle();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows the load-failed state when the script errors, and a later open tries again", async () => {
|
||||
const setPlayerError = vi.fn();
|
||||
const first = mountPlayer(setPlayerError);
|
||||
const [tag] = apiScripts();
|
||||
expect(tag).toBeDefined();
|
||||
tag!.dispatchEvent(new Event("error"));
|
||||
await settle();
|
||||
expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
|
||||
first.unmount();
|
||||
|
||||
// Not memoised as a failure: the next open loads the script again rather than waiting on the
|
||||
// dead promise.
|
||||
const again = mountPlayer(vi.fn());
|
||||
expect(apiScripts()).toHaveLength(1);
|
||||
again.unmount();
|
||||
});
|
||||
|
||||
it("gives up after the timeout when the script never answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setPlayerError = vi.fn();
|
||||
const { unmount } = mountPlayer(setPlayerError);
|
||||
await vi.advanceTimersByTimeAsync(YT_API_TIMEOUT_MS + 1);
|
||||
expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createRef, type MutableRefObject } from "react";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { useYtProgress } from "./playerModalHooks";
|
||||
import { patchCachedProgress } from "./progressCache";
|
||||
import { qk } from "./queryKeys";
|
||||
@@ -73,7 +73,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
|
||||
activeId: VID,
|
||||
resumeAt: 748,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as MutableRefObject<YTPlayer | null>,
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
navRef: { current: { queue: undefined, index: 0 } },
|
||||
modeRef: { current: { autoMode: "off", loopMode: "off" } },
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
@@ -22,7 +21,7 @@ import { api, type Video } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { patchCachedProgress } from "./progressCache";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||
import { logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
|
||||
import type { AutoMode, LoopMode } from "./playerModes";
|
||||
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
@@ -32,19 +31,36 @@ const FINISH_MARGIN = 10;
|
||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||
|
||||
// --- IFrame Player API loader (singleton) ---
|
||||
/** `playerError` when the IFrame API itself never arrived — not one of YouTube's own error codes
|
||||
* (2/5/100/101/150, all positive), and distinct from the -1 an unparseable one maps to. */
|
||||
export const YT_API_LOAD_FAILED = -2;
|
||||
/** How long the script may take before the player gives up on it. The real answer comes in well
|
||||
* under a second; anything near this is a blocked or stalled request, not a slow one. */
|
||||
export const YT_API_TIMEOUT_MS = 15_000;
|
||||
let apiPromise: Promise<YTNamespace> | null = null;
|
||||
function loadYouTubeApi(): Promise<YTNamespace> {
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise<YTNamespace>((resolve) => {
|
||||
apiPromise = new Promise<YTNamespace>((resolve, reject) => {
|
||||
const w = window;
|
||||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||||
const tag = document.createElement("script");
|
||||
// A failure is not memoised: the next open loads the script again instead of waiting on a
|
||||
// promise that already lost (a blocker switched off, the network back).
|
||||
const fail = (why: string) => {
|
||||
window.clearTimeout(timer);
|
||||
apiPromise = null;
|
||||
tag.remove();
|
||||
reject(new Error(`YouTube IFrame API ${why}`));
|
||||
};
|
||||
const timer = window.setTimeout(() => fail("timed out"), YT_API_TIMEOUT_MS);
|
||||
const prev = w.onYouTubeIframeAPIReady;
|
||||
w.onYouTubeIframeAPIReady = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (typeof prev === "function") prev();
|
||||
// The API-ready callback only fires once the script has installed window.YT.
|
||||
resolve(w.YT!);
|
||||
};
|
||||
const tag = document.createElement("script");
|
||||
tag.onerror = () => fail("failed to load");
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
@@ -67,7 +83,7 @@ export function useYtVolume({
|
||||
dialogRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
dialogRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
@@ -188,7 +204,7 @@ export function useNativeControlsYield({
|
||||
stageRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
@@ -234,13 +250,13 @@ export function useYtBigView({
|
||||
focusModal,
|
||||
}: {
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
maximizedRef: MutableRefObject<boolean>;
|
||||
isFullscreenRef: MutableRefObject<boolean>;
|
||||
maximizedRef: RefObject<boolean>;
|
||||
isFullscreenRef: RefObject<boolean>;
|
||||
toggleFullscreen: () => void;
|
||||
toggleTrueFullscreen: () => void;
|
||||
exitBigView: () => void;
|
||||
@@ -327,10 +343,10 @@ export function useYtProgress({
|
||||
activeId: string;
|
||||
resumeAt: number;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
currentIdRef: MutableRefObject<string>;
|
||||
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
|
||||
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
currentIdRef: RefObject<string>;
|
||||
navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
|
||||
modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||||
setWatched: (on: boolean) => void;
|
||||
setPlayerError: (code: number | null) => void;
|
||||
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||||
@@ -340,7 +356,7 @@ export function useYtProgress({
|
||||
// Positions this player session has actually STORED, per video id. Written from the server's
|
||||
// answer, read back by the modal's resume so stepping next→prev inside one session does not
|
||||
// rewind to the value the frozen launch queue was snapshotted with.
|
||||
sessionPosRef: MutableRefObject<Map<string, number>>;
|
||||
sessionPosRef: RefObject<Map<string, number>>;
|
||||
qc: QueryClient;
|
||||
}): void {
|
||||
const autoMarkedRef = useRef(false);
|
||||
@@ -391,66 +407,74 @@ export function useYtProgress({
|
||||
}
|
||||
};
|
||||
|
||||
loadYouTubeApi().then((YT) => {
|
||||
if (cancelled || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
playsinline: 1,
|
||||
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
|
||||
vq: "hd1080",
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
reapplyStored();
|
||||
loadYouTubeApi()
|
||||
.then((YT) => {
|
||||
if (cancelled || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
playsinline: 1,
|
||||
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
|
||||
vq: "hd1080",
|
||||
},
|
||||
onStateChange: (e: YTPlayerEvent) => {
|
||||
const p0 = playerRef.current;
|
||||
logYouTubeState(
|
||||
e?.data,
|
||||
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
|
||||
);
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
if (e?.data === 1) {
|
||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||
const p = playerRef.current;
|
||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||
if (d) setLiveData({ title: d.title, author: d.author });
|
||||
}
|
||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||
if (e?.data === 0 && currentIdRef.current === id) {
|
||||
if (!autoMarkedRef.current) {
|
||||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
events: {
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
reapplyStored();
|
||||
},
|
||||
onStateChange: (e: YTPlayerEvent) => {
|
||||
const p0 = playerRef.current;
|
||||
logYouTubeState(
|
||||
e?.data,
|
||||
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
|
||||
);
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
if (e?.data === 1) {
|
||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||
const p = playerRef.current;
|
||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||
if (d) setLiveData({ title: d.title, author: d.author });
|
||||
}
|
||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||
if (e?.data === 0 && currentIdRef.current === id) {
|
||||
if (!autoMarkedRef.current) {
|
||||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
}
|
||||
advanceOnEnd();
|
||||
}
|
||||
},
|
||||
onError: (e: YTPlayerEvent) => {
|
||||
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
|
||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||||
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||||
// items can't spin. We do NOT mark the broken video watched.
|
||||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||||
const { autoMode: a } = modeRef.current;
|
||||
const { queue: q } = navRef.current;
|
||||
if (a === "off" || !q || q.length <= 1) return;
|
||||
errorStreakRef.current += 1;
|
||||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||||
advanceOnEnd();
|
||||
}
|
||||
},
|
||||
},
|
||||
onError: (e: YTPlayerEvent) => {
|
||||
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
|
||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||||
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||||
// items can't spin. We do NOT mark the broken video watched.
|
||||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||||
const { autoMode: a } = modeRef.current;
|
||||
const { queue: q } = navRef.current;
|
||||
if (a === "off" || !q || q.length <= 1) return;
|
||||
errorStreakRef.current += 1;
|
||||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||||
advanceOnEnd();
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
// The script never arrived (blocked, offline, timed out) or the player could not be built:
|
||||
// an error state with the way out, never an empty stage that waits forever.
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
logYouTubeApiFailed(err);
|
||||
setPlayerError(YT_API_LOAD_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
// The session's positions map (mutated in place, never replaced) — held here for the cleanup.
|
||||
const sessionPos = sessionPosRef.current;
|
||||
@@ -477,7 +501,7 @@ export function useYtProgress({
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
|
||||
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
void Promise.resolve(persist()).finally(() => {
|
||||
// Write this session's stored positions INTO the cached lists first. Marking them stale is
|
||||
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
|
||||
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
|
||||
@@ -511,6 +535,6 @@ export function useYtProgress({
|
||||
//
|
||||
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
|
||||
// object literal, and an effect event may not be passed around as a value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ONE player per video: the captured values are per-item snapshots, live ones are refs
|
||||
}, [activeId]);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("useControlsVisibility", () => {
|
||||
it("hides 3 s after the last activity while playing", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.wake());
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
});
|
||||
|
||||
@@ -36,16 +38,45 @@ describe("useControlsVisibility", () => {
|
||||
result.current.wake();
|
||||
result.current.holdOnHover(true);
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true);
|
||||
|
||||
act(() => result.current.holdOnHover(false));
|
||||
act(() => vi.advanceTimersByTime(2900));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2900);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // a fresh 3 s from the leave, not from the wake
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false);
|
||||
});
|
||||
|
||||
// E28 E6 / round 2 P4: a keyboard focus in the chrome holds the bar — but only for FOCUS_HOLD_MS
|
||||
// after the last key, not for the whole film. The 3 s timer finds the hold, re-arms for what is
|
||||
// left of it, and hides once it is over (E32 rewrote that re-arm without recursion).
|
||||
it("holds for a keyboard focus until 10 s after the last key, then hides", () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.holdOnFocus(true);
|
||||
result.current.wake();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // the hold, not the 3 s
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(6900);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(true); // 9.9 s after the key
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.uiVisible).toBe(false); // the hold is over, focus or not
|
||||
});
|
||||
|
||||
// C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
|
||||
it("toggle hides a shown bar at once and shows a hidden one", () => {
|
||||
const { result } = setup();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRef, type ReactNode, type MutableRefObject } from "react";
|
||||
import { createRef, type ReactNode, type RefObject } from "react";
|
||||
import { usePlexTracks } from "./plexPlayerHooks";
|
||||
import { api, type PlexItemDetail } from "./api";
|
||||
import { dismissError, getError } from "./errorDialog";
|
||||
@@ -49,7 +49,7 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
|
||||
videoRef: createRef<HTMLVideoElement>(),
|
||||
hlsRef: createRef<Hls>(),
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
audioRef: { current: 0 } as RefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: abs },
|
||||
loadStartRef: { current: loadStart },
|
||||
@@ -325,7 +325,7 @@ function TrackHarness() {
|
||||
videoRef: createRef<HTMLVideoElement>(),
|
||||
hlsRef: createRef<Hls>(),
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
audioRef: { current: 0 } as RefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: 3600 },
|
||||
loadStartRef: { current: 0 },
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
@@ -113,16 +112,23 @@ export function useControlsVisibility(opts: {
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
|
||||
const wokeAt = Date.now();
|
||||
const arm = (ms: number) => {
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return;
|
||||
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
|
||||
if (focused.current && held > 0) return arm(held);
|
||||
setVisible(false);
|
||||
}, ms);
|
||||
};
|
||||
arm(HIDE_MS);
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
const busy = () => !!videoRef.current?.paused || menuOpenRef.current || hovered.current;
|
||||
// Not a timer that re-arms itself: a self-calling `arm` read to the compiler's analysis as a
|
||||
// value from outside this callback (memo-dependencies), and a self-naming function expression
|
||||
// tripped one of its invariants. The focus hold re-arms at most once — the second timer fires
|
||||
// when the hold is over, so it has only `busy` left to ask.
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
if (busy()) return;
|
||||
const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
|
||||
if (focused.current && held > 0) {
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
if (!busy()) setVisible(false);
|
||||
}, held);
|
||||
return;
|
||||
}
|
||||
setVisible(false);
|
||||
}, HIDE_MS);
|
||||
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
|
||||
const show = useCallback(
|
||||
(visible: boolean) => {
|
||||
@@ -188,8 +194,8 @@ export function usePlexProgress(opts: {
|
||||
id: string;
|
||||
qc: QueryClient;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
absRef: MutableRefObject<number>;
|
||||
durationRef: MutableRefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
}): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } {
|
||||
const { id, qc, videoRef, absRef, durationRef } = opts;
|
||||
const settleRef = useRef<() => void>(() => {});
|
||||
@@ -341,10 +347,7 @@ export function usePlexProgress(opts: {
|
||||
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
|
||||
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
|
||||
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
|
||||
export function playhead(
|
||||
absRef: MutableRefObject<number>,
|
||||
loadStartRef: MutableRefObject<number>,
|
||||
): number {
|
||||
export function playhead(absRef: RefObject<number>, loadStartRef: RefObject<number>): number {
|
||||
return absRef.current > 0 ? absRef.current : loadStartRef.current;
|
||||
}
|
||||
|
||||
@@ -439,8 +442,8 @@ export function useAutoSkip(opts: {
|
||||
}): {
|
||||
activeMarker: PlexMarker | undefined;
|
||||
skipProgress: number | null;
|
||||
skipProgressRef: MutableRefObject<number | null>;
|
||||
cancelAutoSkipRef: MutableRefObject<() => void>;
|
||||
skipProgressRef: RefObject<number | null>;
|
||||
cancelAutoSkipRef: RefObject<() => void>;
|
||||
doSkip: (m: PlexMarker) => void;
|
||||
} {
|
||||
const {
|
||||
@@ -594,12 +597,12 @@ export function usePlexTracks(opts: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
multiAudioRef: RefObject<boolean>;
|
||||
audioRef: MutableRefObject<number | null>;
|
||||
aoffRef: MutableRefObject<number>;
|
||||
absRef: MutableRefObject<number>;
|
||||
audioRef: RefObject<number | null>;
|
||||
aoffRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
/** The position the current (or still in-flight) session was ASKED to start at — written before
|
||||
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
|
||||
loadStartRef: MutableRefObject<number>;
|
||||
loadStartRef: RefObject<number>;
|
||||
}): {
|
||||
audioOrd: number | null;
|
||||
setAudioOrd: (ord: number | null) => void;
|
||||
@@ -677,7 +680,7 @@ export function usePlexTracks(opts: {
|
||||
}
|
||||
} else {
|
||||
// Single audio (or the session has not answered yet): re-map the track server-side.
|
||||
loadSession(playhead(absRef, loadStartRef));
|
||||
void loadSession(playhead(absRef, loadStartRef));
|
||||
}
|
||||
},
|
||||
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
|
||||
|
||||
@@ -16,7 +16,9 @@ describe("useDebounced", () => {
|
||||
});
|
||||
rerender({ v: "ab" });
|
||||
expect(result.current).toBe("a");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(result.current).toBe("ab");
|
||||
});
|
||||
|
||||
@@ -29,7 +31,9 @@ describe("useDebounced", () => {
|
||||
expect(result.current).toBe("boom");
|
||||
rerender({ v: "boomx", s: 1 });
|
||||
expect(result.current).toBe("boom");
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(result.current).toBe("boomx");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +143,9 @@ describe("usePref", () => {
|
||||
// Local state is instant; the server write is deferred.
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(savePrefs).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
|
||||
});
|
||||
|
||||
@@ -124,10 +124,10 @@ export function usePref<T>(
|
||||
pendingSave.current = flush;
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
pendingSave.current = null;
|
||||
flush();
|
||||
void flush();
|
||||
}, debounceMs);
|
||||
} else {
|
||||
flush();
|
||||
void flush();
|
||||
}
|
||||
},
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
|
||||
@@ -24,21 +24,24 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
// The build-tool configs include two hand-written .js files (tailwind/postcss). checkJs runs
|
||||
// the type checker over them via their `@type` JSDoc, so a bad tailwind/postcss option is
|
||||
// caught by the gate instead of silently mis-building CSS. Was the last hole in C-4.5.
|
||||
// The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config
|
||||
// file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs
|
||||
// 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,
|
||||
"checkJs": true
|
||||
},
|
||||
// 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
|
||||
// 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": [
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"reactPlugin.ts",
|
||||
"devOnlyGuard.ts",
|
||||
"tailwind.config.js",
|
||||
"playwright.config.ts",
|
||||
"postcss.config.js"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user