diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b39d3b5b..c75ded66 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -556,10 +556,10 @@ of a full-width panel and nothing on the right. around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth, Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the - ends are white, a focused end's ring and the playhead amber (a light theme's accent all but vanished - there). A typed time that cannot be read stays in its field, marked invalid; one past the end shows - where it landed (decimal point or comma). The strip's window - follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is + ends are white, a focused end's ring and the playhead the media amber `--media-mark` (the same in both + themes; a light theme's accent and warn all but vanished there). A typed time that cannot be read + stays in its field, marked invalid; one past the end shows where it landed (decimal point or comma). + The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a moment short of it, and while a clip is marked the end of the item never closes the player or steps the queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx index 1a10b008..9119a952 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/components/ClipPanel.tsx @@ -90,8 +90,9 @@ const LAST_MOMENT_S = 0.25; // On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which // all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused. // A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there. -const PLAYHEAD = "bg-warn"; -const END_RING = "focus-visible:outline-warn"; +// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. +const PLAYHEAD = "bg-media-mark"; +const END_RING = "focus-visible:outline-media-mark"; export default function ClipPanel({ src, diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 3e70e41c..69e03af2 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1533,7 +1533,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } {detail?.markers.map((m, i) => (
{ split(); const remove = screen.getByRole("button", { name: "editor.removeCut" }); expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false); + act(() => remove.focus()); fireEvent.click(remove); expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull(); }); + it("hands the focus on to a cut beside it, or to Split here, never to ", async () => { + split(); + const video = document.querySelector("video")!; + Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true }); + fireEvent.timeUpdate(video); + fireEvent.click(screen.getByText("editor.splitHere")); + const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1); + act(() => second!.focus()); + fireEvent.click(second!); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })), + ); + const last = screen.getByRole("button", { name: "editor.removeCut" }); + act(() => last.focus()); + fireEvent.click(last); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")), + ); + }); + it("sits at the cut's foot, under the next segment's keep toggle at its head", () => { split(); const remove = screen.getByRole("button", { name: "editor.removeCut" }); diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 21ec2f07..f334909b 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -42,6 +42,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const qc = useQueryClient(); const videoRef = useRef(null); const trackRef = useRef(null); + const splitRef = useRef(null); const sid = useRef(1); const nid = () => sid.current++; @@ -132,6 +133,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos // --- cut-list mutations --- const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]); + // A cut removed by its button takes the button with it: once it is gone, the focus moves on to the + // cut before it (or the one now first), else to Split here — not to , out of the dialog. + const refocusAfterRemove = useRef(null); + useEffect(() => { + const bi = refocusAfterRemove.current; + if (bi == null) return; + refocusAfterRemove.current = null; + const cut = trackRef.current?.querySelector( + `[data-handle="cut-${Math.max(0, bi - 1)}"]`, + ); + (cut ?? splitRef.current)?.focus(); + }, [boundaries.length]); const splitAtPlayhead = () => { if (creating.current) return; const time = current; @@ -332,6 +345,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos