refactor(ui): every function compiles under the React Compiler but VirtualGrid, and react-hooks/refs is on
E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
This commit is contained in:
+14
-26
@@ -542,9 +542,10 @@ export default tseslint.config(
|
||||
// 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.
|
||||
// this shape yet" (E32 S4 rewrote all 31 try statements it found); the rest had 0 hits when
|
||||
// switched on and are tripwires. `todo` does NOT see every such shape — a conditional, a loop
|
||||
// or an optional call inside a try skips the function with no lint hit at all — so 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",
|
||||
@@ -558,30 +559,17 @@ export default tseslint.config(
|
||||
"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:
|
||||
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings. 66 were fixed or
|
||||
// suppressed inline, each naming its invariant; there is DELIBERATELY no suppressions file — a
|
||||
// bulk `--suppress-all` would hide the next violation instead of answering it.
|
||||
//
|
||||
// react-hooks/refs 217 — OFF, see below.
|
||||
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
|
||||
// sprint and E27 S2 burned the file down to nothing: what survives is
|
||||
// an inline directive at the site, each naming the invariant it
|
||||
// protects. There is DELIBERATELY no suppressions file any more — a
|
||||
// bulk `--suppress-all` would hide the next violation instead of
|
||||
// answering it, and it cannot hold a warning-level rule anyway.
|
||||
//
|
||||
// `refs` is OFF — the one rule here switched off while it has real hits, and recorded as such
|
||||
// in docs/gates.md. E27 turned it off calling 153 of its 217 hits false positives (the popover
|
||||
// engine's `pop.refs.setReference` / `getReferenceProps()`, a popover handle passed as a prop)
|
||||
// and the other ~64 real render-phase `someRef.current = x` writes. The E32 audit showed the
|
||||
// triage was wrong: the rule and the React Compiler share one analysis, so every one of those
|
||||
// "false positives" is a component the compiler SKIPS — 21 popover components among them, and
|
||||
// Channels' 0.9 s row menu was the bill. It stays off only because 245 hits (measured E32 S1)
|
||||
// could not be fixed in one sprint. E32 S3 took them to 31: the popover engine is read through
|
||||
// destructuring (`const { setReference } = pop.refs`), every render-phase write moved to
|
||||
// `useEffectEvent` / `useLatest`. The rest sit in PlexPlayer and PlayerModal, behind the
|
||||
// try/finally and suppression skips; until they are gone, compilerCoverage.contract.test.ts pins
|
||||
// every skipped function (none may be added), and this comes back on.
|
||||
"react-hooks/refs": "off",
|
||||
// `refs` (the other 217) was switched OFF in E27 as mostly false positives, and that triage was
|
||||
// wrong: the rule and the React Compiler share one analysis, so every hit was a function the
|
||||
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill
|
||||
// (E32). E32 S3–S4 fixed every site (245 measured) instead of narrowing the rule: the popover
|
||||
// engine is read through destructuring, render-phase ref writes became `useEffectEvent` /
|
||||
// `useLatest`. It is on, at error, with no exception.
|
||||
"react-hooks/refs": "error",
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/button-primitive": "error",
|
||||
|
||||
@@ -119,33 +119,29 @@ 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);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function createAndAdd() {
|
||||
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);
|
||||
setNewName("");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
||||
|
||||
@@ -45,6 +45,33 @@ function useSeededDraft(baseline: Record<string, string>, seedKey: string) {
|
||||
return { draft, setDraft, secretReset, setSecretReset };
|
||||
}
|
||||
|
||||
/** The dirty keys' writes, one after another. Apart from ConfigPanel's save() so the loop and its
|
||||
* branches sit outside the try (the React Compiler cannot lower either inside one yet, and skipped
|
||||
* the whole panel). */
|
||||
async function writeConfigItems(
|
||||
dirty: ConfigItem[],
|
||||
draft: Record<string, string>,
|
||||
secretReset: Record<string, boolean>,
|
||||
) {
|
||||
for (const it of dirty) {
|
||||
const key = it.key;
|
||||
const raw = draft[key] ?? "";
|
||||
if (it.secret) {
|
||||
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
||||
else if (raw.trim()) await api.setConfig(key, raw);
|
||||
} else if (it.type === "bool") {
|
||||
await api.setConfig(key, raw === "true");
|
||||
} else if (raw === "") {
|
||||
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
|
||||
// youtube_api_proxy) rather than resetting to the env default (AB3).
|
||||
if (it.allow_empty) await api.setConfig(key, "");
|
||||
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
||||
} else {
|
||||
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const panel = useTabPanel();
|
||||
@@ -54,7 +81,6 @@ export default function ConfigPanel() {
|
||||
const data = q.data;
|
||||
|
||||
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
|
||||
const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
|
||||
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
|
||||
// (write-only — we never load them back).
|
||||
const baseline = useMemo(() => {
|
||||
@@ -85,30 +111,13 @@ export default function ConfigPanel() {
|
||||
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
||||
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
|
||||
};
|
||||
const dirtyKeys = items.filter(isDirty).map((i) => i.key);
|
||||
const dirty = dirtyKeys.length > 0;
|
||||
const dirtyItems = items.filter(isDirty);
|
||||
const dirty = dirtyItems.length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaveState("saving");
|
||||
try {
|
||||
for (const key of dirtyKeys) {
|
||||
const it = byKey[key];
|
||||
if (!it) continue; // dirtyKeys are all config-item keys; skip if somehow not found
|
||||
const raw = draft[key] ?? "";
|
||||
if (it.secret) {
|
||||
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
||||
else if (raw.trim()) await api.setConfig(key, raw);
|
||||
} else if (it.type === "bool") {
|
||||
await api.setConfig(key, raw === "true");
|
||||
} else if (raw === "") {
|
||||
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
|
||||
// youtube_api_proxy) rather than resetting to the env default (AB3).
|
||||
if (it.allow_empty) await api.setConfig(key, "");
|
||||
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
||||
} else {
|
||||
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
||||
}
|
||||
}
|
||||
await writeConfigItems(dirtyItems, draft, secretReset);
|
||||
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
|
||||
// The email-not-configured notice reads me.email_configured, which is otherwise only
|
||||
// refetched on its own 60s interval — without this it keeps warning about fields the admin
|
||||
@@ -190,7 +199,7 @@ export default function ConfigPanel() {
|
||||
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
|
||||
// never silently lost behind the global Save bar.
|
||||
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
|
||||
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
|
||||
const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
|
||||
const groupTabs = data.sections.flatMap((s) =>
|
||||
s.groups.map((g) => ({
|
||||
id: g,
|
||||
|
||||
@@ -45,6 +45,16 @@ import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
|
||||
|
||||
const jobKey = (job: DownloadJob) => job.id;
|
||||
|
||||
/** The clipboard's text, or null where the browser refuses. Outside the component: the React
|
||||
* Compiler cannot lower an optional call inside a try yet, and skipped the whole page for it. */
|
||||
async function clipboardText(): Promise<string | null> {
|
||||
try {
|
||||
return (await navigator.clipboard?.readText())?.trim() ?? null;
|
||||
} catch {
|
||||
return null; // permission denied or unsupported — the manual paste button still works on a click
|
||||
}
|
||||
}
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
@@ -115,12 +125,8 @@ export default function DownloadCenter() {
|
||||
// the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text.
|
||||
const pasteFromClipboard = async (force = false) => {
|
||||
if (!force && addUrl.trim()) return;
|
||||
try {
|
||||
const text = (await navigator.clipboard?.readText())?.trim();
|
||||
if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text);
|
||||
} catch {
|
||||
/* permission denied or unsupported — the manual paste button still works on a click */
|
||||
}
|
||||
const text = await clipboardText();
|
||||
if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
|
||||
@@ -41,11 +41,12 @@ export default function DownloadDialog({
|
||||
const submit = async () => {
|
||||
if (!chosen || busy) return;
|
||||
setBusy(true);
|
||||
const displayName = name.trim() || undefined;
|
||||
try {
|
||||
await api.enqueueDownload({
|
||||
source,
|
||||
profile_id: chosen,
|
||||
display_name: name.trim() || undefined,
|
||||
display_name: displayName,
|
||||
});
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
|
||||
@@ -44,7 +44,6 @@ 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!);
|
||||
@@ -55,9 +54,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
||||
} catch {
|
||||
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function toReset() {
|
||||
@@ -77,9 +75,9 @@ 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
|
||||
const confirmPass = hasPassword ? curPass : undefined;
|
||||
try {
|
||||
await api.resetMessageKey(hasPassword ? curPass : undefined);
|
||||
await api.resetMessageKey(confirmPass);
|
||||
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
|
||||
// in setup mode. Invalidate last so the refetch sees configured:false.
|
||||
await e2ee.resetLocal(meId);
|
||||
@@ -88,9 +86,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
||||
} catch (e) {
|
||||
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
if (keyQ.isLoading)
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -14,7 +13,7 @@ import { useNavigationActions } from "./navigationContext";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { PlayerTransportControls } from "./playerModalControls";
|
||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { useLatest, useStableCallback } from "../lib/useLatest";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
@@ -56,13 +56,11 @@ import {
|
||||
formatCompact,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import Toaster from "./ui/Toaster";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { useOverlayOrder } from "./ui/overlayOrder";
|
||||
import { afterRemoval } from "../lib/playerQueue";
|
||||
import {
|
||||
@@ -185,13 +183,12 @@ export default function PlayerModal({
|
||||
// The launch queue is frozen at mount, so its items keep the position they were snapshotted with;
|
||||
// without this map, stepping next→prev back to an item played earlier in the SAME session would
|
||||
// resume at that snapshot and then persist it over the newer position.
|
||||
// A position this session already stored wins over `resumeAt` — it is the freshest truth we have —
|
||||
// so the map is read where the player starts (useYtProgress, loadVideo), never during render.
|
||||
const sessionPosRef = useRef<Map<string, number>>(new Map());
|
||||
const sessionPos = sessionPosRef.current.get(active.id);
|
||||
// startAt only applies to the item we opened with; advanced items resume their own pos. A
|
||||
// position this session already stored wins over both — it is the freshest truth we have.
|
||||
// startAt only applies to the item we opened with; advanced items resume their own pos.
|
||||
const resumeAt =
|
||||
sessionPos ??
|
||||
(startAt != null && active.id === video.id ? startAt : active.position_seconds || 0);
|
||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||||
@@ -277,17 +274,20 @@ export default function PlayerModal({
|
||||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||||
const [playerError, setPlayerError] = useState<number | null>(null);
|
||||
|
||||
const loadVideo = (id: string, start: number | null) => {
|
||||
// Stable callbacks, not plain closures: both reach the player through refs, and the description's
|
||||
// links get them during render (see PlayerDescription).
|
||||
const loadVideo = useStableCallback((id: string, start: number | null) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.loadVideoById !== "function") return;
|
||||
// Explicit start wins; otherwise resume the active item, start others at 0.
|
||||
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
||||
const startSeconds =
|
||||
start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0;
|
||||
setPlayerError(null);
|
||||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||
currentIdRef.current = id;
|
||||
setCurrentVideoId(id);
|
||||
setLiveData(null);
|
||||
};
|
||||
});
|
||||
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
|
||||
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
|
||||
@@ -336,19 +336,18 @@ export default function PlayerModal({
|
||||
currentIdRef.current = active.id;
|
||||
}, [active.id]);
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
const seekTo = useStableCallback((seconds: number) => {
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.seekTo === "function") {
|
||||
p.seekTo(seconds, true);
|
||||
p.playVideo?.();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
||||
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
||||
// current position, not a stale closure.
|
||||
const navRef = useRef({ queue, index });
|
||||
navRef.current = { queue, index };
|
||||
const navRef = useLatest({ queue, index });
|
||||
const goPrev = () => {
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if (q && i > 0) setPlayingId(q[i - 1]!.id);
|
||||
@@ -371,13 +370,9 @@ export default function PlayerModal({
|
||||
if (!onRemoveFromList || removingRef.current) return;
|
||||
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 {
|
||||
const done = await onRemoveFromList(victim).finally(() => {
|
||||
removingRef.current = false;
|
||||
}
|
||||
});
|
||||
if (!done) return; // the request failed — leave playback alone
|
||||
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
||||
// and auto-advance keep working while the request runs, and a cursor from before the await
|
||||
@@ -409,8 +404,7 @@ export default function PlayerModal({
|
||||
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
|
||||
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
|
||||
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
|
||||
const modeRef = useRef({ autoMode, loopMode });
|
||||
modeRef.current = { autoMode, loopMode };
|
||||
const modeRef = useLatest({ autoMode, loopMode });
|
||||
const cycleAuto = () => {
|
||||
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
|
||||
};
|
||||
@@ -609,29 +603,13 @@ export default function PlayerModal({
|
||||
qc,
|
||||
});
|
||||
|
||||
// The description, said the same way in its two places (the popover under 1280 px, the panel
|
||||
// beside the video above it): loading, failed with a retry, the text, or none. One ladder, so the
|
||||
// two cannot drift (E31 review 2: the failed state was tested in the panel only). `wrap` is each
|
||||
// place's own box for the text.
|
||||
const description = (wrap: (body: ReactNode) => ReactNode) =>
|
||||
detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.isError && !detail.data ? (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
) : detail.data?.description ? (
|
||||
wrap(
|
||||
renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
}),
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
);
|
||||
// The description's two places share one ladder (PlayerDescription) and these props.
|
||||
const descriptionProps = {
|
||||
detail,
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
};
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||
@@ -916,15 +894,12 @@ export default function PlayerModal({
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{description((body) => (
|
||||
<div
|
||||
ref={descFadeRef}
|
||||
style={descFadeStyle}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
))}
|
||||
<PlayerDescription
|
||||
ref={descFadeRef}
|
||||
style={descFadeStyle}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
{...descriptionProps}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1105,11 +1080,10 @@ export default function PlayerModal({
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{description((body) => (
|
||||
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{body}
|
||||
</div>
|
||||
))}
|
||||
<PlayerDescription
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
||||
{...descriptionProps}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,6 @@ 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() });
|
||||
@@ -164,9 +163,8 @@ export default function PlaylistsRail({
|
||||
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.syncFailed"));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
|
||||
@@ -71,7 +71,6 @@ 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);
|
||||
@@ -87,30 +86,26 @@ export default function PlexCollectionEditor({
|
||||
// simply never appeared and the user clicked again — a collection deleted on the Plex server
|
||||
// or flipped read-only failed in silence (deep-review round 5).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
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("");
|
||||
@@ -120,16 +115,14 @@ export default function PlexCollectionEditor({
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
|
||||
// it reads in the reader's language (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function removeCollection(id: string, title: string) {
|
||||
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) => {
|
||||
@@ -142,9 +135,8 @@ export default function PlexCollectionEditor({
|
||||
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
|
||||
// row stayed where it was with nothing said.
|
||||
notifyActionError(e, t("plex.collection.deleteFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -148,7 +148,6 @@ 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);
|
||||
@@ -158,9 +157,8 @@ export default function PlexCollectionView({
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
} finally {
|
||||
setQueueBusy(false);
|
||||
}
|
||||
setQueueBusy(false);
|
||||
}
|
||||
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
@@ -207,7 +205,6 @@ 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();
|
||||
@@ -215,9 +212,8 @@ export default function PlexCollectionView({
|
||||
} catch (e) {
|
||||
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
@@ -240,28 +236,24 @@ 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();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const meta = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
import { seekableEndOf } from "../lib/seekableEnd";
|
||||
import {
|
||||
audioOrdForLang,
|
||||
clockParts,
|
||||
@@ -67,7 +69,6 @@ import {
|
||||
useHelpConditions,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyScope,
|
||||
usePaletteOpen,
|
||||
} from "../lib/keymap";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
@@ -137,8 +138,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
|
||||
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
|
||||
// position (losing live progress). See the detail effect's deps.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const tRef = useLatest(t);
|
||||
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
|
||||
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
|
||||
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
|
||||
@@ -197,21 +197,19 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
|
||||
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||
const tonemapRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
const durationRef = useLatest(duration);
|
||||
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
|
||||
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
|
||||
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
|
||||
const fragMeterRef = useRef(createFragMeter());
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
const multiAudioRef = useRef(false);
|
||||
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
|
||||
const multiAudioRef = useLatest((detail?.audio_streams.length ?? 0) > 1);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [abs, setAbs] = useState(0); // absolute current time
|
||||
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
|
||||
const absRef = useRef(0);
|
||||
absRef.current = abs;
|
||||
const absRef = useLatest(abs);
|
||||
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
|
||||
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
|
||||
const loadStartRef = useRef(0);
|
||||
@@ -244,21 +242,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
const { volume, muted } = prefs;
|
||||
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||||
const barAutoHideRef = useRef(prefs.barAutoHide);
|
||||
barAutoHideRef.current = prefs.barAutoHide;
|
||||
const barAutoHideRef = useLatest(prefs.barAutoHide);
|
||||
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
|
||||
// a ref for the same reason `tRef` above does: naming them in that effect's deps would re-run it
|
||||
// when the user picks a track from the player's own menu, double-loading the session the picker
|
||||
// has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
|
||||
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
|
||||
const langPrefsRef = useRef({ audioLang: prefs.audioLang, subLang: prefs.subLang });
|
||||
langPrefsRef.current = { audioLang: prefs.audioLang, subLang: prefs.subLang };
|
||||
const seekStepRef = useRef(prefs.seekStep);
|
||||
seekStepRef.current = prefs.seekStep;
|
||||
const fineSeekStepRef = useRef(prefs.fineSeekStep);
|
||||
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||
const aoffRef = useRef(prefs.audioOffset);
|
||||
aoffRef.current = prefs.audioOffset;
|
||||
const langPrefsRef = useLatest({ audioLang: prefs.audioLang, subLang: prefs.subLang });
|
||||
const seekStepRef = useLatest(prefs.seekStep);
|
||||
const fineSeekStepRef = useLatest(prefs.fineSeekStep);
|
||||
const aoffRef = useLatest(prefs.audioOffset);
|
||||
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
|
||||
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
@@ -267,10 +260,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
"sync",
|
||||
);
|
||||
const audioRef = useRef<number | null>(null);
|
||||
const menuOpenRef = useRef(false);
|
||||
// keep the control bar up while one is open
|
||||
const menuOpenRef = useLatest(menuOpen || tracksOpen || moreOpen);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||
const {
|
||||
uiVisible,
|
||||
@@ -325,6 +318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
fitHeight: true,
|
||||
openOnArrowKey: false,
|
||||
});
|
||||
const { setReference: setTracksReference } = tracksPop.refs;
|
||||
const { ref: tracksFadeRef, style: tracksFadeStyle } = useScrollFade();
|
||||
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||||
const setMoreReference = morePop.refs.setReference;
|
||||
@@ -339,11 +333,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||
// know an overlay is up before it steals a wheel scroll for volume.
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
|
||||
const paletteOpenRef = useRef(false);
|
||||
paletteOpenRef.current = usePaletteOpen();
|
||||
const helpOpenRef = useLatest(helpOpen);
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
@@ -364,26 +354,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
infoOpenRef.current = infoOpen;
|
||||
const infoOpenRef = useLatest(infoOpen);
|
||||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useRef(false);
|
||||
statsOpenRef.current = statsOpen;
|
||||
const statsOpenRef = useLatest(statsOpen);
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useRef(false);
|
||||
upNextOpenRef.current = upNextOpen;
|
||||
const upNextPrefRef = useRef(prefs.upNext);
|
||||
upNextPrefRef.current = prefs.upNext;
|
||||
const upNextOpenRef = useLatest(upNextOpen);
|
||||
const upNextPrefRef = useLatest(prefs.upNext);
|
||||
const now = useWallClock();
|
||||
|
||||
durationRef.current = duration;
|
||||
|
||||
// Track selection is (re)derived from the saved language prefs when each item's detail arrives
|
||||
// (see the detail effect below) — nothing to reset on id change.
|
||||
|
||||
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
||||
// The self-restart path inside loadSession reaches the CURRENT loadSession through this, so the
|
||||
// recursion is not a forward reference to a binding that is still being defined. Declared before
|
||||
// it and set after commit (below) — a render-time write would make the React Compiler skip the
|
||||
// whole player.
|
||||
const loadSessionRef = useRef<(startAt: number, resumePlay?: boolean) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
const loadSession = useCallback(
|
||||
async (startAt: number, resumePlay = true) => {
|
||||
const video = videoRef.current;
|
||||
@@ -421,11 +412,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// 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;
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
// selected client-side after the manifest loads. Decided before the try: the React Compiler
|
||||
// cannot lower a conditional inside one yet.
|
||||
const m = multiAudioRef.current;
|
||||
const audio = m ? null : audioRef.current;
|
||||
try {
|
||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||
// selected client-side after the manifest loads.
|
||||
const m = multiAudioRef.current;
|
||||
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
|
||||
sess = await api.plexSession(id, startAt, audio, aoffRef.current, m);
|
||||
} catch (e) {
|
||||
if (!current()) return; // superseded or left: not this request's error to show
|
||||
settled();
|
||||
@@ -583,7 +576,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
restartsRef.current.n += 1;
|
||||
// Through the ref, not by name: this is loadSession calling ITSELF from inside its own
|
||||
// useCallback, which the checker rightly reads as "accessed before it is declared".
|
||||
// The ref (assigned right below the callback) is the file's existing answer to that —
|
||||
// The ref (declared above the callback, set below it) is the file's answer to that —
|
||||
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
|
||||
void loadSessionRef.current(
|
||||
playhead(absRef, loadStartRef),
|
||||
@@ -609,13 +602,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
);
|
||||
}
|
||||
},
|
||||
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
||||
// NOT `t` — see tRef above (a language switch must not rebuild this / reload the session). The
|
||||
// refs never change.
|
||||
[id, tRef, multiAudioRef, aoffRef, absRef],
|
||||
);
|
||||
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
|
||||
// not a forward reference to a binding that is still being defined.
|
||||
const loadSessionRef = useRef(loadSession);
|
||||
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
|
||||
loadSessionRef.current = loadSession;
|
||||
useLayoutEffect(() => {
|
||||
loadSessionRef.current = loadSession;
|
||||
}, [loadSession]);
|
||||
|
||||
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
|
||||
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
|
||||
@@ -690,7 +683,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
? detail.position_seconds || 0
|
||||
: 0;
|
||||
void loadSession(resume);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, setAudioOrd, setSubOrd]);
|
||||
}, [detail, id, fromStart, loadSession, audioRef, langPrefsRef, setAudioOrd, setSubOrd]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -721,13 +714,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
aliveRef.current = true;
|
||||
const v = videoRef.current;
|
||||
return () => {
|
||||
// Lowering the flag is what tells every in-flight session that its answer belongs to nothing
|
||||
// now (`current()` reads it). The generation is bumped by the "leaving the item" cleanup
|
||||
// above, which an unmount runs too.
|
||||
aliveRef.current = false;
|
||||
// The rule's advice — copy the ref into a variable and use the copy — is precisely wrong here.
|
||||
// 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 -- sessGenRef is a LIVE counter: unmount must bump the current generation, not a copy
|
||||
sessGenRef.current++;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
@@ -752,12 +742,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
|
||||
const a = sessionStartRef.current + video.currentTime;
|
||||
setAbs(a);
|
||||
try {
|
||||
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
|
||||
setSeekableEnd(end);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const end = seekableEndOf(video, sessionStartRef.current, a);
|
||||
if (end != null) setSeekableEnd(end);
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
@@ -823,7 +809,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const dur = durationRef.current || target;
|
||||
const T = Math.max(0, Math.min(target, dur - 1));
|
||||
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
||||
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
|
||||
// too: `absRef` is re-synced from `abs` after every commit, and the restart below renders at once,
|
||||
// so the ref alone was back at the pre-seek spot before anything could read it.
|
||||
absRef.current = T;
|
||||
setAbs(T);
|
||||
@@ -842,7 +828,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
|
||||
}
|
||||
},
|
||||
[loadSession],
|
||||
[loadSession, absRef, durationRef],
|
||||
);
|
||||
|
||||
// --- controls --------------------------------------------------------------------------------
|
||||
@@ -884,7 +870,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
|
||||
const reportWatchedError = useCallback(
|
||||
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
|
||||
[],
|
||||
[tRef],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
@@ -910,10 +896,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// file), loadSession returns without replacing the <video> source, leaving the old episode
|
||||
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
|
||||
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
const outgoing = videoRef.current;
|
||||
if (outgoing) {
|
||||
try {
|
||||
outgoing.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Settle the item being left while the position still is its own (see usePlexProgress), and
|
||||
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
|
||||
@@ -950,7 +939,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const backOrPrev = useCallback(() => {
|
||||
if (playhead(absRef, loadStartRef) <= BACK_TO_PREV_S && prevId) go(prevId);
|
||||
else seekTo(0);
|
||||
}, [prevId, go, seekTo]);
|
||||
}, [prevId, go, seekTo, absRef]);
|
||||
|
||||
// What the last press came from. A touch tap on the picture only shows or hides the controls (C3:
|
||||
// measured live, a tap used to reveal the bar AND pause); a mouse click there still plays/pauses.
|
||||
@@ -1001,7 +990,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
}
|
||||
toggleControls();
|
||||
},
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible],
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible, absRef],
|
||||
);
|
||||
|
||||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||||
@@ -1103,7 +1092,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
};
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError]);
|
||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
@@ -1134,7 +1123,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
else if (statsOpenRef.current) setStatsOpen(false);
|
||||
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
}, [onClose, menuOpenRef, infoOpenRef, statsOpenRef, upNextOpenRef]);
|
||||
|
||||
// The mouse "back" button (and the browser Back) fire history popstate — which would pop the whole
|
||||
// player subview and drop straight to the grid, even with a panel or an auto-skip countdown up.
|
||||
@@ -1330,6 +1319,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
handleBack,
|
||||
skipProgressRef,
|
||||
cancelAutoSkipRef,
|
||||
absRef,
|
||||
seekStepRef,
|
||||
fineSeekStepRef,
|
||||
menuOpenRef,
|
||||
infoOpenRef,
|
||||
statsOpenRef,
|
||||
upNextOpenRef,
|
||||
]);
|
||||
|
||||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
||||
@@ -1768,7 +1764,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
variant="overlay"
|
||||
size="player"
|
||||
round
|
||||
ref={tracksPop.refs.setReference}
|
||||
ref={setTracksReference}
|
||||
{...tracksPop.getReferenceProps()}
|
||||
aria-expanded={tracksOpen}
|
||||
label={t("plex.player.tracks")}
|
||||
|
||||
@@ -68,7 +68,6 @@ 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);
|
||||
@@ -83,17 +82,15 @@ export default function PlexPlaylistAdd({
|
||||
e,
|
||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
);
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
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);
|
||||
@@ -108,10 +105,9 @@ export default function PlexPlaylistAdd({
|
||||
// The create may already have landed when the add fails — `refresh()` in `finally` is what
|
||||
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,18 @@ import { IconButton } from "./ui/IconButton";
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
|
||||
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
|
||||
/** Dropped / total frames so far. Outside the component: the React Compiler cannot lower an
|
||||
* optional call inside a try yet, and skipped the whole overlay for it. */
|
||||
function frameCounts(v: HTMLVideoElement | null): { dropped: number; total: number } {
|
||||
try {
|
||||
const q = v?.getVideoPlaybackQuality?.();
|
||||
if (q) return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames };
|
||||
} catch {
|
||||
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
||||
}
|
||||
return { dropped: 0, total: 0 };
|
||||
}
|
||||
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
@@ -47,17 +59,7 @@ export function PlexStatsOverlay({
|
||||
const v = videoRef.current;
|
||||
const hls = hlsRef.current;
|
||||
|
||||
let dropped = 0;
|
||||
let total = 0;
|
||||
try {
|
||||
const q = v?.getVideoPlaybackQuality?.();
|
||||
if (q) {
|
||||
dropped = q.droppedVideoFrames;
|
||||
total = q.totalVideoFrames;
|
||||
}
|
||||
} catch {
|
||||
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
|
||||
}
|
||||
const { dropped, total } = frameCounts(v);
|
||||
|
||||
let bufferAhead = 0;
|
||||
if (v) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -150,21 +150,19 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
||||
// before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through a ref: the seed must fire on the
|
||||
// views query's first resolve for a source and never again, which `seededRef` enforces. Listing
|
||||
// cfg would re-arm nothing (the guard still holds) but would re-run the effect on every keystroke
|
||||
// in the filter bar. The ref keeps the dep list honest without that.
|
||||
const cfgRef = useRef(cfg);
|
||||
cfgRef.current = cfg;
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
||||
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
|
||||
// on every keystroke in the filter bar.
|
||||
const latestCfg = useEffectEvent(() => cfg);
|
||||
useEffect(() => {
|
||||
const data = viewsQuery.data;
|
||||
if (!data || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const c = cfgRef.current;
|
||||
const c = latestCfg();
|
||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||
if (!def) return;
|
||||
c.setFilters(c.asFilters(def.filters));
|
||||
setStickyId(def.id);
|
||||
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
||||
if (def) c.setFilters(c.asFilters(def.filters));
|
||||
}, [viewsQuery.data, source]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
@@ -190,16 +188,15 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
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
|
||||
let ok = true;
|
||||
try {
|
||||
await fn();
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, failMsg);
|
||||
return false;
|
||||
} finally {
|
||||
refresh();
|
||||
ok = false;
|
||||
}
|
||||
refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const value: SavedViewsContext = {
|
||||
|
||||
@@ -367,24 +367,22 @@ 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") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||
} finally {
|
||||
setSessBusy(false);
|
||||
}
|
||||
setSessBusy(false);
|
||||
};
|
||||
|
||||
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
|
||||
const confirmCurrent = me.has_password ? current : undefined;
|
||||
try {
|
||||
await api.setPassword(next, me.has_password ? current : undefined);
|
||||
await api.setPassword(next, confirmCurrent);
|
||||
setOpen(false);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
@@ -398,9 +396,8 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
setErr(
|
||||
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
// Offer Google only when this instance has Google OAuth configured (or the account is already
|
||||
|
||||
@@ -84,49 +84,53 @@ 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))
|
||||
return setError(t("setup.admin.invalidEmail"));
|
||||
if (password.length < PASSWORD_MIN)
|
||||
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
|
||||
await api.setupAdmin(token, email, password);
|
||||
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
|
||||
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
|
||||
await api.setupConfig(token, {
|
||||
google_client_id: gid.trim(),
|
||||
google_client_secret: gsecret.trim(),
|
||||
});
|
||||
} else if (step === "smtp" && smtp.host.trim()) {
|
||||
await api.setupConfig(token, {
|
||||
smtp_host: smtp.host.trim(),
|
||||
smtp_port: Number(smtp.port) || 587,
|
||||
smtp_user: smtp.user.trim(),
|
||||
smtp_from: smtp.from.trim(),
|
||||
smtp_password: smtp.password,
|
||||
});
|
||||
} else if (step === "finish") {
|
||||
await api.setupFinish(token);
|
||||
window.location.href = "/"; // configured now → reloads into the login page
|
||||
return;
|
||||
}
|
||||
go(1);
|
||||
await saveStep();
|
||||
} catch (e) {
|
||||
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
// next()'s steps, apart so `busy` clears on every way out without a try/finally (the React
|
||||
// Compiler cannot lower one yet, and skips the whole component).
|
||||
async function saveStep() {
|
||||
if (step === "admin") {
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return setError(t("setup.admin.invalidEmail"));
|
||||
if (password.length < PASSWORD_MIN)
|
||||
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
|
||||
await api.setupAdmin(token, email, password);
|
||||
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
|
||||
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
|
||||
await api.setupConfig(token, {
|
||||
google_client_id: gid.trim(),
|
||||
google_client_secret: gsecret.trim(),
|
||||
});
|
||||
} else if (step === "smtp" && smtp.host.trim()) {
|
||||
await api.setupConfig(token, {
|
||||
smtp_host: smtp.host.trim(),
|
||||
smtp_port: Number(smtp.port) || 587,
|
||||
smtp_user: smtp.user.trim(),
|
||||
smtp_from: smtp.from.trim(),
|
||||
smtp_password: smtp.password,
|
||||
});
|
||||
} else if (step === "finish") {
|
||||
await api.setupFinish(token);
|
||||
window.location.href = "/"; // configured now → reloads into the login page
|
||||
return;
|
||||
}
|
||||
go(1);
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTestState("sending");
|
||||
const port = Number(smtp.port) || 587;
|
||||
try {
|
||||
// Persist the SMTP fields first so the test uses them, then send.
|
||||
if (smtp.host.trim())
|
||||
await api.setupConfig(token, {
|
||||
smtp_host: smtp.host.trim(),
|
||||
smtp_port: Number(smtp.port) || 587,
|
||||
smtp_port: port,
|
||||
smtp_user: smtp.user.trim(),
|
||||
smtp_from: smtp.from.trim(),
|
||||
smtp_password: smtp.password,
|
||||
|
||||
@@ -71,9 +71,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
const displayName = name.trim() || undefined;
|
||||
try {
|
||||
await api.uploadDownload(file, {
|
||||
displayName: name.trim() || undefined,
|
||||
displayName,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
@@ -41,6 +41,19 @@ type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const MIN_SEG = 0.1;
|
||||
|
||||
/** Queues each clip as its own job, in order; `done` collects the ids that made it, so the caller's
|
||||
* catch can say how many did when one fails part-way. */
|
||||
async function enqueueClips(
|
||||
jobId: number,
|
||||
clips: { id: number; spec: EditSpec; dn: string | undefined }[],
|
||||
done: number[],
|
||||
) {
|
||||
for (const c of clips) {
|
||||
await api.enqueueEdit({ source_job_id: jobId, edit_spec: c.spec, display_name: c.dn });
|
||||
done.push(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -331,19 +344,20 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
? prev
|
||||
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
|
||||
series.current = suffix;
|
||||
// Every clip's request is built before the try, and queued by a helper: the React Compiler
|
||||
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
|
||||
const clips = kept.map((s) => {
|
||||
const spec: EditSpec = {
|
||||
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
||||
};
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
||||
return { id: s.id, spec, dn };
|
||||
});
|
||||
const done: number[] = [];
|
||||
try {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||
const spec: EditSpec = {
|
||||
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
||||
};
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
done.push(s.id);
|
||||
}
|
||||
await enqueueClips(job.id, clips, done);
|
||||
} catch (e) {
|
||||
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
|
||||
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
|
||||
|
||||
@@ -31,14 +31,15 @@ function useMediaSession(
|
||||
useEffect(() => {
|
||||
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
||||
const video = videoRef.current;
|
||||
const init: MediaMetadataInit = {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url
|
||||
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
||||
: [],
|
||||
};
|
||||
try {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url
|
||||
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
||||
: [],
|
||||
});
|
||||
navigator.mediaSession.metadata = new MediaMetadata(init);
|
||||
navigator.mediaSession.setActionHandler("play", () => void video?.play());
|
||||
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
|
||||
} catch {
|
||||
@@ -197,38 +198,41 @@ export default function WatchPage() {
|
||||
setAttempt((n) => n + 1);
|
||||
};
|
||||
|
||||
// unlock()'s steps, apart so `unlocking` clears on every way out without a try/finally (the React
|
||||
// Compiler cannot lower one yet, and skips the whole page).
|
||||
const unlockSteps = async () => {
|
||||
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (r.status === 403) {
|
||||
setError(T.wrong);
|
||||
return;
|
||||
}
|
||||
// Unlocking is rate-limited per link: too many tries is not a dead link either.
|
||||
if (r.status === 429) {
|
||||
setError(T.tooMany);
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
};
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (r.status === 403) {
|
||||
setError(T.wrong);
|
||||
return;
|
||||
}
|
||||
// Unlocking is rate-limited per link: too many tries is not a dead link either.
|
||||
if (r.status === 429) {
|
||||
setError(T.tooMany);
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
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");
|
||||
await unlockSteps();
|
||||
} catch {
|
||||
setError(T.unlockFailed);
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
setUnlocking(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -494,14 +494,12 @@ 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
|
||||
const resetWith = resetToken ?? "";
|
||||
try {
|
||||
if (mode === "signin") {
|
||||
await api.passwordLogin(mail, password);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (mode === "register") {
|
||||
} else if (mode === "register") {
|
||||
await api.register(mail, password);
|
||||
setDone(t("welcome.auth.registerDone"));
|
||||
} else if (mode === "forgot") {
|
||||
@@ -511,7 +509,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
await api.resendVerification(mail);
|
||||
setDone(t("welcome.auth.resendDone"));
|
||||
} else if (mode === "reset") {
|
||||
await api.confirmPasswordReset(resetToken ?? "", password);
|
||||
await api.confirmPasswordReset(resetWith, password);
|
||||
setResetToken(null); // single-use: the reset view is no longer a place to come back to
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
}
|
||||
@@ -521,21 +519,18 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
|
||||
if (refusal && PANEL_REASONS.has(refusal.reason)) {
|
||||
setBlocked(refusal);
|
||||
return;
|
||||
}
|
||||
// The token died between the pre-flight check and this submit — or was never checked, because
|
||||
// the probe itself failed. Either way the answer is the same screen, not a red line.
|
||||
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
||||
} else if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
||||
// The token died between the pre-flight check and this submit — or was never checked,
|
||||
// because the probe itself failed. Either way the answer is the same screen, not a red line.
|
||||
setDeadReset(true);
|
||||
setResetToken(null);
|
||||
return;
|
||||
} else {
|
||||
setError(
|
||||
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
||||
);
|
||||
}
|
||||
setError(
|
||||
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function onDemo(e: React.SubmitEvent<HTMLFormElement>) {
|
||||
@@ -547,16 +542,14 @@ 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();
|
||||
else setDemoError(t("welcome.auth.demoDenied"));
|
||||
} catch {
|
||||
setDemoError(t("welcome.auth.demoDenied"));
|
||||
} finally {
|
||||
setDemoBusy(false);
|
||||
}
|
||||
setDemoBusy(false);
|
||||
}
|
||||
|
||||
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
|
||||
|
||||
@@ -13,38 +13,14 @@ import { compileReport, shippedSources, type CompileReport } from "./compilerSki
|
||||
// • a NEW skip (or a known one changing category) — fix the function; if it truly cannot be
|
||||
// compiled, adding it here is a decision for the owner, with the reason in docs/gates.md;
|
||||
// • a skip that is GONE — delete its entry, so the list only ever shrinks.
|
||||
// E32 burns it down: S3 took it from 93 to 29 (the popover engine's `ref={pop.refs.setReference}`
|
||||
// — read through destructuring now — and every render-phase ref write, moved to `useEffectEvent` /
|
||||
// `useLatest`). Left: try/finally and other Todo shapes, and the two exhaustive-deps suppressions.
|
||||
// The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
|
||||
// E32 burned it down from 93 to the one function below: S3 read the popover engine through
|
||||
// destructuring and moved every render-phase ref write to `useEffectEvent` / `useLatest`; S4 took
|
||||
// try/finally and every conditional, loop or optional call out of try blocks (the compiler cannot
|
||||
// lower them yet — and `react-hooks/todo` does not see the latter, so THIS test is what catches them)
|
||||
// and replaced the two exhaustive-deps suppressions, each of which skipped its whole function.
|
||||
// VirtualGrid stays: TanStack Virtual's API cannot be memoized safely (IncompatibleLibrary).
|
||||
const KNOWN_SKIPS: CompileReport["skipped"] = {
|
||||
"components/AddToPlaylist.tsx": { MembershipPanel: "Todo" },
|
||||
"components/ConfigPanel.tsx": { ConfigPanel: "Todo" },
|
||||
"components/DownloadCenter.tsx": { DownloadCenter: "Todo" },
|
||||
"components/DownloadDialog.tsx": { DownloadDialog: "Todo" },
|
||||
"components/KeyGate.tsx": { KeyGate: "Todo" },
|
||||
"components/PlayerModal.tsx": { PlayerModal: "Todo" },
|
||||
"components/PlaylistsRail.tsx": { PlaylistsRail: "Todo" },
|
||||
"components/PlexCollectionEditor.tsx": { PlexCollectionEditor: "Todo" },
|
||||
"components/PlexCollectionView.tsx": { PlexCollectionView: "Todo" },
|
||||
"components/PlexPlayer.tsx": { PlexPlayer: "Suppression" },
|
||||
"components/PlexPlaylistAdd.tsx": { PlexPlaylistAdd: "Todo" },
|
||||
"components/PlexStatsOverlay.tsx": { PlexStatsOverlay: "Todo" },
|
||||
"components/SavedViewsProvider.tsx": { SavedViewsProvider: "Todo" },
|
||||
"components/SettingsPanel.tsx": { SignInMethods: "Todo" },
|
||||
"components/SetupWizard.tsx": { SetupWizard: "Todo" },
|
||||
"components/UploadDialog.tsx": { UploadDialog: "Todo" },
|
||||
"components/VideoEditor.tsx": { VideoEditor: "Todo" },
|
||||
"components/WatchPage.tsx": { WatchPage: "Todo", useMediaSession: "Todo" },
|
||||
"components/Welcome.tsx": { AuthCard: "Todo" },
|
||||
"components/plexViews.tsx": { PlexSeasonView: "Todo", PlexShowView: "Todo" },
|
||||
"components/ui/VirtualGrid.tsx": { VirtualGrid: "IncompatibleLibrary" },
|
||||
"components/useDebugRecording.ts": { useDebugRecording: "Todo" },
|
||||
"components/useDownloadActions.tsx": { useDownloadActions: "Todo" },
|
||||
"components/usePlaylistActions.tsx": { usePlaylistActions: "Todo" },
|
||||
"components/usePlexPlaylistEdits.ts": { usePlexPlaylistEdits: "Todo" },
|
||||
"lib/playerModalHooks.ts": { useYtProgress: "Suppression" },
|
||||
"lib/plexPlayerHooks.ts": { usePlexTracks: "Todo" },
|
||||
};
|
||||
|
||||
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
|
||||
@@ -56,6 +32,10 @@ const MUST_COMPILE: Record<string, string[]> = {
|
||||
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
|
||||
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
|
||||
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
|
||||
// The two players — the largest components here, and the last to compile (E32 S4).
|
||||
"components/PlexPlayer.tsx": ["PlexPlayer"],
|
||||
"components/PlayerModal.tsx": ["PlayerModal"],
|
||||
"lib/playerModalHooks.ts": ["useYtProgress"],
|
||||
};
|
||||
|
||||
type Row = [file: string, fn: string, category: string];
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { CSSProperties, Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import type { VideoDetail } from "../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
||||
import { Button } from "./ui/Button";
|
||||
import { StateMessage } from "./ui/QueryState";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||
@@ -101,3 +105,41 @@ export function PlayerTransportControls({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
||||
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
||||
* ladder, so the two cannot drift (E31 review 2: the failed state was tested in the panel only).
|
||||
* `ref` / `style` / `className` dress the text's box only. */
|
||||
export function PlayerDescription({
|
||||
detail,
|
||||
currentId,
|
||||
onSeek,
|
||||
onLoadVideo,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
}: {
|
||||
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
||||
currentId: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
onLoadVideo: (id: string, start: number | null) => void;
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
||||
if (detail.isError && !detail.data)
|
||||
return (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
);
|
||||
const text = detail.data?.description;
|
||||
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
||||
return (
|
||||
<div ref={ref} style={style} className={className}>
|
||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -466,14 +466,12 @@ 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) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
@@ -800,20 +798,19 @@ 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) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markEpisode(ep: PlexCard) {
|
||||
const next = ep.status === "watched" ? "new" : "watched";
|
||||
try {
|
||||
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
|
||||
await api.plexSetState(ep.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ afterEach(cleanup);
|
||||
|
||||
function Card({ open }: { open: boolean }) {
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: () => {}, mode: "hover" });
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<>
|
||||
<div tabIndex={-1} data-testid="card" ref={pop.refs.setReference}>
|
||||
<div tabIndex={-1} data-testid="card" ref={setReference}>
|
||||
card
|
||||
</div>
|
||||
<PopoverSurface popover={pop}>
|
||||
|
||||
@@ -6,5 +6,5 @@ import { createStore } from "../../lib/store";
|
||||
export const backToTopShown = createStore(false);
|
||||
|
||||
export function useBackToTopShown(): boolean {
|
||||
return backToTopShown.use();
|
||||
return backToTopShown.useValue();
|
||||
}
|
||||
|
||||
@@ -14,20 +14,20 @@ afterEach(cleanup);
|
||||
|
||||
function Editor() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const focus = useReturnFocus(open);
|
||||
const { target, arm } = useReturnFocus(open);
|
||||
return open ? (
|
||||
<input
|
||||
aria-label="name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
focus.arm(e);
|
||||
arm(e);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button ref={focus.target} onClick={() => setOpen(true)}>
|
||||
<button ref={target} onClick={() => setOpen(true)}>
|
||||
Rename
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -106,16 +106,14 @@ 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)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
} finally {
|
||||
hide(false);
|
||||
}
|
||||
hide(false);
|
||||
if (image) shoot.mutate({ id: session.id, image });
|
||||
};
|
||||
|
||||
|
||||
@@ -89,12 +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 {
|
||||
setSharingId(null);
|
||||
}
|
||||
await quickShareLink(qc, job, t).finally(() => setSharingId(null));
|
||||
};
|
||||
|
||||
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function usePlaylistActions() {
|
||||
const copy = useCopyItems();
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { pushing, reverting } = inFlight.use();
|
||||
const { pushing, reverting } = inFlight.useValue();
|
||||
|
||||
const refresh = (id: number) => {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
@@ -131,7 +131,6 @@ 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);
|
||||
@@ -141,91 +140,94 @@ export function usePlaylistActions() {
|
||||
});
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.revertFailed"));
|
||||
} finally {
|
||||
mark("reverting", pl.id, false);
|
||||
}
|
||||
mark("reverting", pl.id, false);
|
||||
}
|
||||
|
||||
async function push(pl: Menuable) {
|
||||
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 =
|
||||
plan.action === "update" &&
|
||||
!plan.add_yt &&
|
||||
!plan.add_local &&
|
||||
!plan.remove_yt &&
|
||||
!plan.remove_local &&
|
||||
!plan.to_reorder &&
|
||||
!plan.rename;
|
||||
if (nothingToDo) {
|
||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name }) });
|
||||
return;
|
||||
}
|
||||
if (!plan.affordable) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushNoQuota", {
|
||||
left: plan.remaining_today,
|
||||
units: plan.units_estimate,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (plan.action === "create") {
|
||||
message = t("playlists.pushPlanCreate", {
|
||||
count: plan.add_yt,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
});
|
||||
} else {
|
||||
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||
const here: string[] = [];
|
||||
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||
if (plan.remove_local)
|
||||
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||
const yt: string[] = [];
|
||||
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||||
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||||
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||||
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||||
const sentences = [...here, ...yt];
|
||||
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||||
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||||
const quota =
|
||||
plan.units_estimate > 0
|
||||
? t("playlists.mergeQuota", {
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
})
|
||||
: t("playlists.mergeQuotaFree");
|
||||
message = sentences.join(" ") + quota;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: t("playlists.pushTitle"),
|
||||
message,
|
||||
confirmLabel: t("playlists.pushConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
const r = await api.pushPlaylist(pl.id);
|
||||
refresh(pl.id);
|
||||
if (r.failures.length) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushPartial", { count: r.failures.length }),
|
||||
});
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name }) });
|
||||
}
|
||||
await pushSteps(pl, name);
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.pushFailed"));
|
||||
} finally {
|
||||
mark("pushing", pl.id, false);
|
||||
}
|
||||
mark("pushing", pl.id, false);
|
||||
}
|
||||
|
||||
/** push()'s steps, apart so its "pushing" mark clears on every way out without a try/finally
|
||||
* (the React Compiler cannot lower one yet, and skips the whole hook). */
|
||||
async function pushSteps(pl: Menuable, name: string) {
|
||||
const plan = await api.playlistPushPlan(pl.id);
|
||||
const nothingToDo =
|
||||
plan.action === "update" &&
|
||||
!plan.add_yt &&
|
||||
!plan.add_local &&
|
||||
!plan.remove_yt &&
|
||||
!plan.remove_local &&
|
||||
!plan.to_reorder &&
|
||||
!plan.rename;
|
||||
if (nothingToDo) {
|
||||
notify({ level: "info", message: t("playlists.pushUpToDate", { name }) });
|
||||
return;
|
||||
}
|
||||
if (!plan.affordable) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushNoQuota", {
|
||||
left: plan.remaining_today,
|
||||
units: plan.units_estimate,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (plan.action === "create") {
|
||||
message = t("playlists.pushPlanCreate", {
|
||||
count: plan.add_yt,
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
});
|
||||
} else {
|
||||
// One sentence per side that actually changes; the untouched side gets a short "stays
|
||||
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
|
||||
const here: string[] = [];
|
||||
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
|
||||
if (plan.remove_local)
|
||||
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
|
||||
const yt: string[] = [];
|
||||
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
|
||||
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
|
||||
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
|
||||
if (plan.rename) yt.push(t("playlists.mergeRename"));
|
||||
const sentences = [...here, ...yt];
|
||||
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
|
||||
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
|
||||
const quota =
|
||||
plan.units_estimate > 0
|
||||
? t("playlists.mergeQuota", {
|
||||
units: plan.units_estimate,
|
||||
left: plan.remaining_today,
|
||||
})
|
||||
: t("playlists.mergeQuotaFree");
|
||||
message = sentences.join(" ") + quota;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: t("playlists.pushTitle"),
|
||||
message,
|
||||
confirmLabel: t("playlists.pushConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
const r = await api.pushPlaylist(pl.id);
|
||||
refresh(pl.id);
|
||||
if (r.failures.length) {
|
||||
notify({
|
||||
level: "warning",
|
||||
message: t("playlists.pushPartial", { count: r.failures.length }),
|
||||
});
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name }) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +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
|
||||
let removed = true;
|
||||
try {
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
@@ -104,10 +104,10 @@ export function usePlexPlaylistEdits(playlistId: number) {
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
removed = false;
|
||||
}
|
||||
setBusy(false);
|
||||
if (!removed) return;
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("plex.playlist.removedToast", { title }),
|
||||
|
||||
@@ -8,14 +8,14 @@ const startOpen = createStore(false);
|
||||
/** Open the start dialog (Settings → Diagnosztika, the palette). */
|
||||
export const openRecorderStart = (): void => startOpen.set(true);
|
||||
export const closeRecorderStart = (): void => startOpen.set(false);
|
||||
export const useRecorderStartOpen = (): boolean => startOpen.use();
|
||||
export const useRecorderStartOpen = (): boolean => startOpen.useValue();
|
||||
|
||||
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
|
||||
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
|
||||
// one (a remount). The prompt must come along with what was typed.
|
||||
const stepPrompt = createStore<string | null>(null);
|
||||
/** The step prompt's text while it is open, else null. */
|
||||
export const useStepPrompt = (): string | null => stepPrompt.use();
|
||||
export const useStepPrompt = (): string | null => stepPrompt.useValue();
|
||||
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
|
||||
|
||||
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
|
||||
@@ -42,7 +42,7 @@ const composing = createStore<string | null>(null);
|
||||
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
|
||||
export const openComposer = (id: string): void => composing.set(id);
|
||||
export const closeComposer = (): void => composing.set(null);
|
||||
export const useComposerSession = (): string | null => composing.use();
|
||||
export const useComposerSession = (): string | null => composing.useValue();
|
||||
|
||||
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
||||
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
||||
@@ -61,7 +61,7 @@ function readSteps(): Record<string, number> {
|
||||
const steps = createStore<Record<string, number>>(readSteps());
|
||||
|
||||
/** How many steps session `id` has marked in this tab. */
|
||||
export const useStepCount = (id: string): number => steps.use()[id] ?? 0;
|
||||
export const useStepCount = (id: string): number => steps.useValue()[id] ?? 0;
|
||||
|
||||
/** Take the next step number for session `id`. */
|
||||
export function nextStep(id: string): number {
|
||||
|
||||
@@ -20,7 +20,7 @@ const surprise = createStore<number | null>(null);
|
||||
export const requestSurprise = (): void => surprise.set(ensureNavId());
|
||||
|
||||
/** The pending token (null = nothing pending) — the render subscription that wakes Home up. Home only. */
|
||||
export const useSurpriseRequest = (): number | null => surprise.use();
|
||||
export const useSurpriseRequest = (): number | null => surprise.useValue();
|
||||
|
||||
/** Take the pending request, clearing it; true only when it was requested for the entry Home is on.
|
||||
*
|
||||
|
||||
@@ -416,7 +416,7 @@ const tokens: { scope: KeyScope }[] = [];
|
||||
|
||||
/** The live scopes, innermost first, always ending in `global`. Re-renders on change. */
|
||||
export function useActiveScopes(): KeyScope[] {
|
||||
const stack = scopeStack.use();
|
||||
const stack = scopeStack.useValue();
|
||||
// DEDUPED: a scope can be registered by more than one live component (the Playlists page and its
|
||||
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
|
||||
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
|
||||
@@ -449,14 +449,14 @@ export const toggleKeyboardHelp = (): void => {
|
||||
helpOpen.set(next);
|
||||
};
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
|
||||
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
|
||||
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.use();
|
||||
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.useValue();
|
||||
|
||||
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
|
||||
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
|
||||
const navigablePages = createStore<readonly Page[]>([]);
|
||||
export const useNavigablePages = (): readonly Page[] => navigablePages.use();
|
||||
export const useNavigablePages = (): readonly Page[] => navigablePages.useValue();
|
||||
|
||||
// ── The command palette ───────────────────────────────────────────────────────────────────────
|
||||
const paletteOpen = createStore(false);
|
||||
@@ -464,7 +464,7 @@ const paletteOpen = createStore(false);
|
||||
export const closePalette = (): void => paletteOpen.set(false);
|
||||
/** Open it from a visible control (the header's key cap / launcher), not only from Ctrl/Cmd+K. */
|
||||
export const openPalette = (): void => paletteOpen.set(true);
|
||||
export const usePaletteOpen = (): boolean => paletteOpen.use();
|
||||
export const usePaletteOpen = (): boolean => paletteOpen.useValue();
|
||||
|
||||
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
|
||||
* either overlay because both are opened by it: a listener that sat in one of them would have to
|
||||
@@ -576,7 +576,7 @@ export function useSearchFocusKey(input: RefObject<HTMLInputElement | null>): vo
|
||||
}, [input]);
|
||||
}
|
||||
|
||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.useValue();
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
||||
const GLYPHS: Record<string, string> = {
|
||||
|
||||
@@ -315,6 +315,21 @@ export function useYtBigView({
|
||||
};
|
||||
}
|
||||
|
||||
/** The player's position and length, or null when there is no usable player — not built yet, or
|
||||
* tearing down (its methods throw then). Outside the hook: the React Compiler cannot lower a
|
||||
* conditional inside a try yet, and skipped useYtProgress for it. */
|
||||
function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
|
||||
if (!p || typeof p.getCurrentTime !== "function") return null;
|
||||
try {
|
||||
return {
|
||||
cur: p.getCurrentTime(),
|
||||
dur: typeof p.getDuration === "function" ? p.getDuration() : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
|
||||
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
|
||||
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
|
||||
@@ -358,10 +373,34 @@ export function useYtProgress({
|
||||
}): void {
|
||||
const autoMarkedRef = useRef(false);
|
||||
const errorStreakRef = useRef(0);
|
||||
// The render values this player is built from, read ONCE when it is built (see the dep note at
|
||||
// the end of the effect).
|
||||
const itemSnapshot = useEffectEvent(() => ({
|
||||
resumeAt,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = activeId;
|
||||
const {
|
||||
resumeAt,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
} = itemSnapshot();
|
||||
// A position this session already stored for the item beats the queue's snapshot of it.
|
||||
const startAt = sessionPosRef.current.get(id) ?? resumeAt;
|
||||
autoMarkedRef.current = false;
|
||||
setPlayerError(null);
|
||||
|
||||
@@ -379,28 +418,23 @@ export function useYtProgress({
|
||||
return false;
|
||||
};
|
||||
const persist = (): Promise<unknown> | void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||
// position, racing the watched write against the progress row it deletes).
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||
// user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return (
|
||||
api
|
||||
.saveProgress(id, cur, dur)
|
||||
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
|
||||
// cleared to 0 there, and the resume must agree with the row, not with our reading.
|
||||
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* player may be tearing down */
|
||||
const times = playerTimes(playerRef.current);
|
||||
if (!times) return;
|
||||
const { cur, dur } = times;
|
||||
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||
// position, racing the watched write against the progress row it deletes).
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||
// user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return (
|
||||
api
|
||||
.saveProgress(id, cur, dur)
|
||||
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
|
||||
// cleared to 0 there, and the resume must agree with the row, not with our reading.
|
||||
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -413,7 +447,7 @@ export function useYtProgress({
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
start: startAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
@@ -480,15 +514,9 @@ export function useYtProgress({
|
||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
|
||||
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
|
||||
const onHide = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||||
} catch {
|
||||
/* player tearing down */
|
||||
}
|
||||
if (currentIdRef.current !== id) return;
|
||||
const times = playerTimes(playerRef.current);
|
||||
if (times && times.cur > 0 && times.dur > 0) api.saveProgressBeacon(id, times.cur, times.dur);
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
|
||||
@@ -517,21 +545,18 @@ export function useYtProgress({
|
||||
}
|
||||
playerRef.current = null;
|
||||
};
|
||||
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
|
||||
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
|
||||
// every listener attached to it.
|
||||
// ONE PLAYER PER VIDEO is the invariant: re-running would destroy and rebuild the YouTube
|
||||
// iframe, losing the playing position and every listener attached to it. So the deps are the id
|
||||
// and refs (which never change).
|
||||
//
|
||||
// This body DOES capture render values — `resumeAt`, `setWatched`, `advanceOnEnd`,
|
||||
// `reapplyStored`, `focusModal`, `qc` — and it must, because each of them is a PER-ITEM
|
||||
// SNAPSHOT: they describe the video this player was built for, and re-reading them mid-session
|
||||
// would be the bug, not the fix. `resumeAt` is the clearest case, since persist() writes
|
||||
// sessionPosRef and so changes it while `activeId` has not moved; the player is already playing
|
||||
// from it and must not be told again. What genuinely has to stay live mid-session is behind refs
|
||||
// (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and
|
||||
// THAT split is the enforcement: a value that is not a snapshot does not belong in this closure.
|
||||
//
|
||||
// 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 -- ONE player per video: the captured values are per-item snapshots, live ones are refs
|
||||
}, [activeId]);
|
||||
// The render values the body uses — `resumeAt`, `setWatched`, `advanceOnEnd`, `reapplyStored`,
|
||||
// `focusModal`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
|
||||
// player is built: they describe the video it was built for, and re-reading them mid-session
|
||||
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
|
||||
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
|
||||
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
|
||||
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
|
||||
// a value that is not a snapshot does not belong in this closure. (E32: this replaced an
|
||||
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
|
||||
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
|
||||
}
|
||||
|
||||
@@ -670,8 +670,9 @@ export function usePlexTracks(opts: {
|
||||
});
|
||||
if (multiAudioRef.current && hlsRef.current) {
|
||||
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
|
||||
const track = ord ?? 0;
|
||||
try {
|
||||
hlsRef.current.audioTrack = ord ?? 0;
|
||||
hlsRef.current.audioTrack = track;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ describe("preferSticky", () => {
|
||||
expect(preferSticky([v(1), v(2)], 2)?.id).toBe(2);
|
||||
});
|
||||
|
||||
it("without a sticky match, prefers the starred default over an earlier twin", () => {
|
||||
// The first-load seed applies the default's filters and leaves the name to this tie-break: an
|
||||
// earlier view with the same filters must not take the default's name (E32).
|
||||
const d = (id: number, is_default = false) => ({ id, name: `v${id}`, is_default });
|
||||
expect(preferSticky([d(1), d(2, true)], null)?.id).toBe(2);
|
||||
expect(preferSticky([d(1), d(2, true)], 99)?.id).toBe(2);
|
||||
expect(preferSticky([d(1), d(2, true)], 1)?.id).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the first match when the sticky id isn't among the matches", () => {
|
||||
expect(preferSticky([v(1), v(2)], 99)?.id).toBe(1);
|
||||
expect(preferSticky([v(1), v(2)], null)?.id).toBe(1);
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
|
||||
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
|
||||
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
|
||||
* silently re-attributed to an earlier duplicate A; otherwise the first match. Null if none match. */
|
||||
export function preferSticky<T extends { id: number }>(
|
||||
* silently re-attributed to an earlier duplicate A; else the starred default, which is what the
|
||||
* first-load seed applies (so the seed needs no sticky write of its own); else the first match.
|
||||
* Null if none match. */
|
||||
export function preferSticky<T extends { id: number; is_default?: boolean }>(
|
||||
matches: T[],
|
||||
stickyId: number | null,
|
||||
): T | null {
|
||||
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
|
||||
return (
|
||||
matches.find((v) => v.id === stickyId) ??
|
||||
matches.find((v) => v.is_default) ??
|
||||
matches[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** The starred default view to seed a module's filters with on first load, or null to leave them.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Where the media can be sought to, on the session's absolute clock (`now` while the element has
|
||||
* no range yet), or null when the element refuses to say. Its own function, outside PlexPlayer: the
|
||||
* React Compiler cannot lower a conditional inside a try yet, and skipped the whole player for it. */
|
||||
export function seekableEndOf(
|
||||
video: HTMLVideoElement,
|
||||
sessionStart: number,
|
||||
now: number,
|
||||
): number | null {
|
||||
try {
|
||||
return video.seekable.length ? sessionStart + video.seekable.end(0) : now;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// A store's `.use()` is a hook the React Compiler cannot see: its name is a method's, not `use…`'s.
|
||||
// Called straight from a COMPONENT, the compiled render memoized its result and never re-rendered on
|
||||
// a change — Back to top stayed hidden (E28 UAT, measured under vitest's compiler build). Inside a
|
||||
// function named `use…` it is compiled as a hook and works, so that is the only place it may be read.
|
||||
describe("a store's use()", () => {
|
||||
it("is read only inside a function named use…", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const offenders: string[] = [];
|
||||
for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) {
|
||||
if (!/\.tsx?$/.test(f) || f.includes(".test.")) continue;
|
||||
const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8");
|
||||
for (const m of src.matchAll(/\b\w+\.use\(\)/g)) {
|
||||
const before = src.slice(0, m.index);
|
||||
const owners = [...before.matchAll(/(?:function\s+(\w+)|const\s+(\w+)\s*=\s*\()/g)];
|
||||
const owner = owners[owners.length - 1];
|
||||
const name = owner?.[1] ?? owner?.[2] ?? "";
|
||||
if (!name.startsWith("use")) offenders.push(`${f}: ${m[0]} in ${name || "?"}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A store is read from React through `useValue()`, a name the React Compiler knows as a hook. Its
|
||||
// old name, `use()`, it did not: the call was compiled as an ordinary one and memoized, so it ran
|
||||
// on the first render alone. Read straight from a component, Back to top never re-rendered (E28);
|
||||
// read inside a hook that compiles memo scopes around it, usePlaylistActions rendered fewer hooks
|
||||
// on its second render and threw (E32 S4, the moment that hook started to compile). Vitest builds
|
||||
// with the same compiler as the app (reactPlugin), so this renders the compiled code.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, act, cleanup } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { createStore } from "./store";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const count = createStore(0);
|
||||
|
||||
// A hook shaped like usePlaylistActions: other hooks around the read, and a callback built from it.
|
||||
function useCountLabel(prefix: string) {
|
||||
const [step] = useState(1);
|
||||
const value = count.useValue();
|
||||
const next = () => count.set(value + step);
|
||||
return { label: `${prefix}${value}`, next };
|
||||
}
|
||||
|
||||
function Counter() {
|
||||
const { label, next } = useCountLabel("n=");
|
||||
return <button onClick={next}>{label}</button>;
|
||||
}
|
||||
|
||||
function Bare() {
|
||||
return <span>{`bare=${count.useValue()}`}</span>;
|
||||
}
|
||||
|
||||
describe("a store read from React", () => {
|
||||
it("re-renders a compiled hook and a compiled component on every change", () => {
|
||||
act(() => count.set(0));
|
||||
render(
|
||||
<>
|
||||
<Counter />
|
||||
<Bare />
|
||||
</>,
|
||||
);
|
||||
act(() => count.set(1));
|
||||
expect(screen.getByRole("button").textContent).toBe("n=1");
|
||||
expect(screen.getByText("bare=1")).toBeTruthy();
|
||||
act(() => screen.getByRole("button").click());
|
||||
expect(screen.getByRole("button").textContent).toBe("n=2");
|
||||
expect(screen.getByText("bare=2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,11 @@ export interface Store<T> {
|
||||
get(): T;
|
||||
set(next: T | ((prev: T) => T)): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
/** Subscribe from a component; re-renders on every change. */
|
||||
use(): T;
|
||||
/** Subscribe from a component; re-renders on every change. Named `use…` on purpose: the React
|
||||
* Compiler knows a hook only by that name, and a plain `use()` was compiled as an ordinary call —
|
||||
* memoized, so it ran on the first render alone (E32: usePlaylistActions then rendered fewer hooks
|
||||
* and threw; E28: Back to top never re-rendered). */
|
||||
useValue(): T;
|
||||
}
|
||||
|
||||
export function createStore<T>(initial: T): Store<T> {
|
||||
@@ -28,6 +31,6 @@ export function createStore<T>(initial: T): Store<T> {
|
||||
listeners.forEach((l) => l());
|
||||
},
|
||||
subscribe,
|
||||
use: () => useSyncExternalStore(subscribe, get, get),
|
||||
useValue: () => useSyncExternalStore(subscribe, get, get),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ export function dockUnderToasts(component: ComponentType): () => void {
|
||||
}
|
||||
|
||||
/** What is docked (the Toaster reads it). */
|
||||
export const useToastDock = (): ComponentType | null => docked.use();
|
||||
export const useToastDock = (): ComponentType | null => docked.useValue();
|
||||
|
||||
Reference in New Issue
Block a user