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:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user