From 4d4eea2c544965b6ba366eb0e606222d0eda76d0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 15:57:02 +0200 Subject: [PATCH] fix(clip): marks on the player chrome keep their amber in a light theme, and a removed cut hands the focus on - A media amber (`--media-mark`), the same in both themes, for what marks a place on the always-dark chrome or over a video: the clip strip's playhead and focused end, the crop frame's ring, Plex's intro / credits markers. A light theme's warn was a dark amber there, under 3:1 (contract-tested). - The editor's remove-cut button rings inside itself (the track clipped its ring), and once it goes with its cut the focus moves to the cut beside it, or to Split here, not to . --- docs/DESIGN.md | 8 +++---- frontend/src/components/ClipPanel.tsx | 5 +++-- frontend/src/components/PlexPlayer.tsx | 2 +- .../VideoEditor.partial.dom.test.tsx | 21 +++++++++++++++++++ frontend/src/components/VideoEditor.tsx | 21 +++++++++++++++++-- frontend/src/components/ui/CropFrame.tsx | 6 ++++-- frontend/src/index.css | 4 ++++ frontend/src/lib/theme.contract.test.ts | 7 +++++++ 8 files changed, 63 insertions(+), 11 deletions(-) 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