feat(player): clip mode - mark a range of what is playing and cut it into a download

Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
2026-10-10 03:58:27 +02:00
parent f221c1962a
commit 50157cc2fd
28 changed files with 2121 additions and 602 deletions
+26
View File
@@ -41,6 +41,19 @@ describe("matches", () => {
expect(matches(press({ key: "k", ctrlKey: true, altKey: true }), "Mod+k")).toBe(false);
});
// …but a symbol the layout can only type through AltGr IS that symbol: on the HU layout `[` is
// AltGr+F (Ctrl+Alt on Windows), and a bare `[` row must answer it, or the clip marks are dead keys.
it("matches a bare-symbol row typed through AltGr, never a letter row or a Mod row", () => {
const altGr = (key: string) =>
press({ key, ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
expect(matches(altGr("["), "[")).toBe(true);
expect(matches(altGr("]"), "]")).toBe(true);
expect(matches(altGr("a"), "a")).toBe(false);
expect(matches(altGr("k"), "Mod+k")).toBe(false);
// A real Ctrl+Alt chord (no AltGr) on a symbol is still not the bare symbol.
expect(matches(press({ key: "[", ctrlKey: true, altKey: true }), "[")).toBe(false);
});
it("treats Mod as Ctrl or Cmd", () => {
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
@@ -111,10 +124,23 @@ describe("dispatch", () => {
subtitle: noop,
info: noop,
stats: noop,
clip: () => calls.push("clip"),
markIn: () => calls.push("markIn"),
markOut: () => calls.push("markOut"),
back: () => calls.push("back"),
escape: () => calls.push("escape"),
}) as const;
it("routes the clip marks, typed plain or through AltGr", () => {
const calls: string[] = [];
const h = plexHandlers(calls);
dispatch(press({ key: "c" }), "plexPlayer", h);
dispatch(press({ key: "[" }), "plexPlayer", h);
const altGr = { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit;
dispatch(press({ key: "]", ...altGr }), "plexPlayer", h);
expect(calls).toEqual(["clip", "markIn", "markOut"]);
});
it("routes each arrow variant to its own id", () => {
const calls: string[] = [];
const h = plexHandlers(calls);