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:
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user