From c27cfdcb9c6d3c4446f1fde5081935d0951fbf83 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 10 Oct 2026 20:28:52 +0200 Subject: [PATCH] fix(clip): typed times belong to their value, and every mark on the player chrome can be seen TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so leaving the field never commits it to another cut; it is invalid only once offered, with a line under it saying how to type a time. On the always-dark chrome media-scope points every focus ring at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under 3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed. --- docs/DESIGN.md | 14 +++- .../src/components/ClipPanel.dom.test.tsx | 2 +- frontend/src/components/ClipPanel.tsx | 13 ++-- frontend/src/components/PlexPlayer.tsx | 3 +- .../VideoEditor.partial.dom.test.tsx | 63 +++++++++++++++ frontend/src/components/VideoEditor.tsx | 10 ++- frontend/src/components/ui/README.md | 14 ++-- .../src/components/ui/RangeTrack.dom.test.tsx | 33 ++++++-- frontend/src/components/ui/RangeTrack.tsx | 46 ++++++++--- .../components/ui/TimecodeField.dom.test.tsx | 57 ++++++++++++++ frontend/src/components/ui/TimecodeField.tsx | 76 +++++++++++++------ frontend/src/components/ui/fieldStyles.ts | 5 +- frontend/src/i18n/locales/en/common.json | 2 + frontend/src/i18n/locales/en/editor.json | 2 +- frontend/src/i18n/locales/hu/common.json | 2 + frontend/src/i18n/locales/hu/editor.json | 2 +- frontend/src/index.css | 15 +++- frontend/src/lib/theme.contract.test.ts | 15 ++++ 18 files changed, 303 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/ui/TimecodeField.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c75ded66..81d4c054 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -112,7 +112,10 @@ style guide. own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes, because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus - (owner decision F16). Black and white are the media scrims and their text. Five schemes × dark + (owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a + Plex marker) and every focus ring, and `--media-danger` an error: a form there (`media-scope`) re-points + `--focus` and `--danger` to them, as a light theme's accent and danger are dark on the chrome + (measured in `theme.contract.test.ts`). Black and white are the media scrims and their text. Five schemes × dark and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. @@ -556,9 +559,11 @@ 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 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). + ends are white, every focus ring in the panel 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 once offered (Enter, leaving it), marked invalid with a line under it saying + how to type one; a time typed over a mark that then moves (a nudge, another segment) is dropped; 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 @@ -760,3 +765,4 @@ of a full-width panel and nothing on the right. | 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | | 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) | | 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture | +| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-scope` points its focus rings (`--focus`) and errors at them | The theme's accent ring, warn and danger | On the always-dark chrome a light theme's accent ring (Slate 2.8:1), warn (2.3:1) and danger (2.8:1) all fell under 3:1 | diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/components/ClipPanel.dom.test.tsx index 30154b48..eabc570c 100644 --- a/frontend/src/components/ClipPanel.dom.test.tsx +++ b/frontend/src/components/ClipPanel.dom.test.tsx @@ -137,7 +137,7 @@ describe("ClipPanel", () => { expect(range()).toBe("102.5-130"); }); - it("never seeks the player onto the item's very end (that would end it, and the clip with it)", () => { + it("never seeks the player onto the item's very end (that would finish the item)", () => { const onSeek = vi.fn(); render(); fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" }); diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/components/ClipPanel.tsx index 9119a952..ca202381 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/components/ClipPanel.tsx @@ -84,15 +84,17 @@ interface Props { const NUDGE_S = 0.1; // How far short of the item's end a seek stops: a mark on the very end moves the player to its last -// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on) -// and takes the clip with it. +// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks +// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only +// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps +// the item unfinished.) 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. -// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. +// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every +// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's +// is: the theme accent's ring was under 3:1 on the chrome. const PLAYHEAD = "bg-media-mark"; -const END_RING = "focus-visible:outline-media-mark"; export default function ClipPanel({ src, @@ -247,7 +249,6 @@ export default function ClipPanel({ playhead={current} playheadClassName={PLAYHEAD} onSeek={seek} - handleRingClassName={END_RING} className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45" background={ formatTimecode(s, hours).replace(/\.\d$/, "")} /> diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 69e03af2..e797d4e0 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -950,7 +950,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // (`useHistorySubview`'s back). // - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown. // - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player. - // - The arrow leaves past a clip as it does past a panel; the keys step out of it. + // - The arrow still closes an open panel or the stats first, but it leaves past a clip, as past a + // countdown: only the keys step out of a clip. const back = useCallback( (from: "arrow" | "key" | "escape") => { if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current(); diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/components/VideoEditor.partial.dom.test.tsx index ee6fb873..96459b7a 100644 --- a/frontend/src/components/VideoEditor.partial.dom.test.tsx +++ b/frontend/src/components/VideoEditor.partial.dom.test.tsx @@ -102,6 +102,41 @@ describe("VideoEditor — a split export that fails part-way", () => { }); }); +// A time typed into the selected segment's field belongs to that segment: selecting another one +// drops it, and never commits it to the new selection's cut. +describe("VideoEditor — a typed time and another segment", () => { + it("does not move the newly selected segment's cut with a time typed for the old one", () => { + vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); + render( + + {}} /> + , + ); + const video = document.querySelector("video")!; + for (const at of [30, 60]) { + Object.defineProperty(video, "currentTime", { + value: at, + configurable: true, + writable: true, + }); + fireEvent.timeUpdate(video); + fireEvent.click(screen.getByText("editor.splitHere")); + } + const segs = () => screen.getAllByTitle("editor.keptSeg"); + fireEvent.pointerDown(segs()[1]!); + const inField = screen.getByRole("textbox", { name: "editor.in" }); + act(() => inField.focus()); + fireEvent.change(inField, { target: { value: "0:40" } }); + // Another segment is selected before the field loses the focus (a press on it). + fireEvent.pointerDown(segs()[2]!); + fireEvent.blur(inField); + const cuts = screen + .getAllByRole("slider", { name: "editor.cutN" }) + .map((c) => c.getAttribute("aria-valuenow")); + expect(cuts).toEqual(["30", "60"]); + }); +}); + // The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a // tenth short of the next cut, so no segment ever closes up. describe("VideoEditor — a cut on the keyboard", () => { @@ -172,6 +207,34 @@ describe("VideoEditor — a cut's remove button", () => { ); }); + it("a remove refused while the clips are queued leaves no focus to take later", async () => { + let refuse: (e: unknown) => void = () => {}; + vi.spyOn(api, "enqueueEdit").mockImplementation( + () => + new Promise((_, reject) => { + refuse = reject; + }) as never, + ); + split(); + fireEvent.click(screen.getByText("editor.createN")); + await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled()); + // The cut list holds still while the clips are queued: the remove is refused. + const remove = screen.getByRole("button", { name: "editor.removeCut" }); + act(() => remove.focus()); + fireEvent.click(remove); + expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy(); + await act(async () => refuse(new Error("refused"))); + // The create failed and the editor stays; a later split keeps the focus where it was. + const video = document.querySelector("video")!; + Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true }); + fireEvent.timeUpdate(video); + const splitButton = screen.getByText("editor.splitHere").closest("button")!; + act(() => splitButton.focus()); + fireEvent.click(splitButton); + expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2); + expect(document.activeElement).toBe(splitButton); + }); + 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 f334909b..74648050 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -435,12 +435,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos e.stopPropagation()} onClick={() => { + // Refused while the clips are queued (the list holds still): then nothing is + // removed, and no focus is left waiting to be taken by a later change. + if (creating.current) return; refocusAfterRemove.current = bi; deleteBoundary(bi); }} - className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger focus-visible:-outline-offset-2" - aria-label={t("editor.removeCut")} - title={t("editor.removeCut")} + // 24px, the target floor; white on its black disc in either theme. + className="pointer-events-auto absolute bottom-0 -right-0.5 p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white focus-visible:-outline-offset-2" + aria-label={t("editor.removeCut", { n: bi + 1 })} + title={t("editor.removeCut", { n: bi + 1 })} > diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index a1024716..a8779ca3 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage ## What lives here -| area | components | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | -| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `