fix(keys): epic-end review round 3 — the part-way clip count shows, busy buttons keep focus, cards say their names

Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed.

- VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was
  written for (req had already reported the error); it now always shows. The cut list holds still while
  Create queues, and a retry continues the "(i/N)" series.
- ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe
  block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on
  hover.
- listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message
  rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot.
- AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus
  back); the dead panel-ref check is gone.
- AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames
  the message for an unreachable server. Pager comment points at scrollParent.
This commit is contained in:
2026-09-13 20:47:13 +02:00
parent 3cd0c525e2
commit c6b552e75f
18 changed files with 312 additions and 98 deletions
+35 -6
View File
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
import { notifyActionError } from "../lib/actionErrors";
import { failureReason, notifyActionError } from "../lib/actionErrors";
import { markReported } from "../lib/api/core";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -60,6 +61,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
// Create started with, so the cut list holds still until it is done: a split or a merge in between
// would deselect a range that was never queued, or keep one that was (deep-review E11 round 3).
const creating = useRef(false);
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
const series = useRef<Map<number, string> | null>(null);
const [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false);
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
@@ -147,7 +156,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
const moveBoundary = (bi: number, time: number) =>
setSegments((segs) => {
if (bi < 0 || bi >= segs.length - 1) return segs;
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
@@ -159,12 +168,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
});
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (segs.length < 2) return segs;
if (creating.current || segs.length < 2) return segs;
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
setSegments((segs) =>
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
);
// --- timeline drag (seek / boundary) + hover preview ---
const [drag, setDrag] = useState<Drag | null>(null);
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const create = useMutation({
mutationFn: async () => {
creating.current = true;
const cp = cropPx();
const base = name.trim() || defaultName;
if (willJoin) {
@@ -307,6 +319,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: 1 };
}
const N = kept.length;
const prev = series.current;
const suffix =
prev && kept.every((s) => prev.has(s.id))
? prev
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
series.current = suffix;
const done: number[] = [];
try {
for (let i = 0; i < N; i++) {
@@ -316,7 +334,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
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);
}
@@ -329,7 +347,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
notifyActionError(e, t("editor.partlyCreated", { done: done.length, total: N }));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
// what the dialog does not tell (deep-review E11 round 3).
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
}
throw e;
}
@@ -342,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
});
const sel = segments.find((s) => s.id === selId) ?? segments[0];