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
+15
View File
@@ -545,6 +545,20 @@ of a full-width panel and nothing on the right.
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
preview — no second stream. Plex: the panel takes the seek bar's and the row's place. YouTube: it sits under
the video (the card's fit counts it, so the video shrinks to make room), and a big view steps back to the
card. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip
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 and the playhead amber (a light theme's accent all but vanished there). 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. Escape closes the panel before the
player; the scissors gets the focus back. A cut is a toast with a Downloads link and the usual
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`).
## Dialogs — _Decided_ (C4, built in S3f)
@@ -730,3 +744,4 @@ of a full-width panel and nothing on the right.
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 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) |
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
//
// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes,
// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says
// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import type { ClipRequest } from "../lib/api";
import type { ClipRange } from "../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
});
const notify = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) };
});
const notifyActionError = vi.fn();
vi.mock("../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipSource } from "./ClipPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// A block, not an expression: a function returned from beforeEach is run as its teardown.
enqueueClip.mockResolvedValue({ id: 1 });
});
function Harness({
src = { source: "plex", ref: "777", audioTrack: 1 },
start = { start: 100, end: 130 },
current = 100,
onClose = () => {},
onPlayFrom = () => {},
onSeek = () => {},
}: {
src?: ClipSource;
start?: ClipRange;
current?: number;
onClose?: () => void;
onPlayFrom?: (t: number) => void;
onSeek?: (t: number, final: boolean) => void;
}) {
const [clip, setClip] = useState(start);
return (
<QueryClientProvider client={new QueryClient()}>
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
<ClipPanel
src={src}
clip={clip}
onChange={setClip}
duration={3600}
current={current}
onSeek={onSeek}
onPlayFrom={onPlayFrom}
onClose={onClose}
/>
</QueryClientProvider>
);
}
const range = () => screen.getByTestId("range").textContent;
describe("ClipPanel", () => {
it("opens with the focus on its In handle, so the arrows move the mark at once", () => {
render(<Harness />);
const handle = screen.getByRole("slider", { name: "clip.in" });
expect(document.activeElement).toBe(handle);
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
expect(range()).toBe("99-130");
});
it("takes a typed time on Enter and carries the other end along past it", () => {
render(<Harness />);
const [inField] = screen.getAllByRole("textbox", { name: "clip.in" });
fireEvent.change(inField!, { target: { value: "2:20" } });
fireEvent.keyDown(inField!, { key: "Enter" });
expect(range()).toBe("140-170");
});
it("marks an end at the playhead", () => {
render(<Harness current={115.5} />);
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
expect(range()).toBe("100-115.5");
});
it("cuts the range with the Plex audio track and closes", async () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: " The bit " },
});
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueueClip).toHaveBeenCalledWith({
source: "plex",
ref: "777",
start_s: 100.123,
end_s: 130,
audio_track: 1,
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }),
);
});
it("sends no audio track for a YouTube clip", async () => {
render(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
const body = enqueueClip.mock.calls[0]![0];
expect(body.source).toBe("youtube");
expect(Object.keys(body)).not.toContain("audio_track");
});
it("stays open and says so when the cut is refused", async () => {
const onClose = vi.fn();
const err = new Error("boom");
enqueueClip.mockRejectedValue(err);
render(<Harness onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
);
});
it("loops the selection: playing past End goes back to Start", () => {
const onPlayFrom = vi.fn();
const { rerender } = render(<Harness onPlayFrom={onPlayFrom} current={110} />);
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
onPlayFrom.mockClear();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
expect(onPlayFrom).not.toHaveBeenCalled();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
});
});
+336
View File
@@ -0,0 +1,336 @@
import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { log } from "../lib/log";
import { formatTimecode, parseTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
//
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
// around the selection where the handles are. The strip's window follows the selection when it
// settles, not while a handle is being dragged — a window that moved under the pointer would run
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
// precision, and the fields and their tenth-of-a-second steps give the rest.
//
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
// mark the same range by the same rules (lib/clipRange).
export interface ClipSource {
source: ClipRequest["source"];
ref: string;
/** Plex: the audio track that is playing, which the clip carries. */
audioTrack?: number | null;
}
interface Props {
src: ClipSource;
clip: ClipRange;
onChange: (r: ClipRange) => void;
duration: number;
/** The player's position, live. */
current: number;
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
onSeek: (time: number, final: boolean) => void;
/** Play from `time` (the loop's restart, and "Play selection"). */
onPlayFrom: (time: number) => void;
onClose: () => void;
}
const NUDGE_S = 0.1;
// 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.
const PLAYHEAD = "bg-warn";
export default function ClipPanel({
src,
clip,
onChange,
duration,
current,
onSeek,
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
const hours = duration >= 3600;
const tc = (s: number) => formatTimecode(s, hours);
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once.
useEffect(() => {
rootRef.current?.querySelector<HTMLElement>('[data-handle="in"]')?.focus();
}, []);
// "Play selection" loops: past Out, back to In.
const restart = useEffectEvent(() => onPlayFrom(clip.start));
useEffect(() => {
if (looping && current >= clip.end) restart();
}, [looping, current, clip.end]);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markIn(clip, time, duration));
onSeek(time, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markOut(clip, time, duration));
onSeek(time, final);
};
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
setBusy(false);
}
};
return (
<form
ref={rootRef}
onSubmit={submit}
aria-label={t("clip.title")}
data-testid="clip-panel"
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<span className="flex shrink-0 items-center gap-1.5 font-medium">
<Scissors className="h-4 w-4" aria-hidden="true" />
{t("clip.title")}
</span>
<RangeTrack
label={t("clip.overview")}
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && onSeek(time, true)}
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
regions={[
{
key: "window",
start: view.from,
end: view.to,
className: "border-x border-white/50",
},
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
]}
/>
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
<X />
</IconButton>
</div>
<RangeTrack
label={t("clip.strip")}
duration={duration}
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={onSeek}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
}
regions={[
{
key: "sel",
start: clip.start,
end: clip.end,
className: "bg-accent/25 border-y-2 border-accent",
},
]}
handles={[
{
id: "in",
label: t("clip.in"),
time: clip.start,
...bounds.in,
valueText: tc(clip.start),
step: NUDGE_S,
bigStep: 1,
onChange: moveIn,
barClassName: "bg-white",
},
{
id: "out",
label: t("clip.out"),
time: clip.end,
...bounds.out,
valueText: tc(clip.end),
step: NUDGE_S,
bigStep: 1,
onChange: moveOut,
barClassName: "bg-white",
},
]}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<MarkField
label={t("clip.in")}
value={clip.start}
text={tc(clip.start)}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
onHere={() => moveIn(current, true)}
/>
<MarkField
label={t("clip.out")}
value={clip.end}
text={tc(clip.end)}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
</span>
<Button
variant="overlay"
size="sm"
pressed={looping}
icon={<Repeat />}
onClick={() => {
if (!looping) onPlayFrom(clip.start);
setLooping(!looping);
}}
>
{t("clip.loop")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={255}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
/>
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
</Button>
</div>
</form>
);
}
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
* and "to the playhead". */
function MarkField({
label,
value,
text,
onSet,
nudge,
hereLabel,
onHere,
}: {
label: string;
value: number;
text: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
const commit = (raw: string) => {
const v = parseTimecode(raw);
if (v != null && Math.abs(v - value) > 1e-3) onSet(v);
};
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value - nudge)}
label={t("clip.earlier", { label })}
>
<Minus />
</IconButton>
<Input
// Re-created when the mark moves elsewhere, so the field shows it; a typed draft lives in the
// field until Enter or blur.
key={text}
size="sm"
className="w-24 text-center tabular-nums"
defaultValue={text}
aria-label={label}
onBlur={(e) => commit(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value + nudge)}
label={t("clip.later", { label })}
>
<Plus />
</IconButton>
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
<Crosshair />
</IconButton>
</div>
);
}
@@ -476,7 +476,10 @@ describe("PlayerModal — no side strips", () => {
);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
@@ -574,14 +577,17 @@ describe("PlayerModal — fits the window's height", () => {
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > .player-stage") ? 630 : 0;
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
@@ -626,7 +632,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
const info = screen.getByTestId("player-info");
// The card is a row: the video, then the panel holding the title and the channel.
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
@@ -656,7 +662,8 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
expect(screen.queryByTestId("player-info")).toBeNull();
const strip = screen.getByTestId("player-strip");
@@ -722,7 +729,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element) => {
@@ -818,3 +825,56 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
expect(maximised(false)).toBe(1);
});
});
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
// not the player, the scissors gets the focus back, and a mark made in the big view steps back to
// the card, where the panel lives.
describe("PlayerModal — clip mode", () => {
const key = (k: string) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }),
),
);
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("opens under the video on C, and Escape closes the panel, not the player", async () => {
const onClose = vi.fn();
clock.current = 42;
open(onClose);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
const panel = screen.getByTestId("clip-panel");
const stage = document.querySelector(".player-stage")!;
expect(stage.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"42",
);
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks in the big view by stepping back to the card", async () => {
clock.current = 10;
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
expect(document.querySelector(".player-stage--max")).toBeTruthy();
key("]");
expect(document.querySelector(".player-stage--max")).toBeNull();
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"10",
);
});
});
+114 -2
View File
@@ -28,6 +28,7 @@ import {
Minimize2,
PanelRightClose,
PanelRightOpen,
Scissors,
Settings,
SkipBack,
SkipForward,
@@ -50,6 +51,8 @@ import {
} from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
import ClipPanel from "./ClipPanel";
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import {
@@ -338,6 +341,56 @@ export default function PlayerModal({
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.id;
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
// the video, which is its preview; another video starts without one.
const [clip, setClip] = useState<ClipRange | null>(null);
const [clipFor, setClipFor] = useState(currentVideoId);
if (clipFor !== currentVideoId) {
setClipFor(currentVideoId);
setClip(null);
}
const clipOpen = clip != null;
// The player's clock, read while the panel is open: the iframe reports no time of its own.
const [clipClock, setClipClock] = useState({ now: 0, duration: 0 });
useEffect(() => {
if (!clipOpen) return;
const read = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
const now = p.getCurrentTime() || 0;
const duration = p.getDuration() || 0;
// Paused, the clock stands still: no new state, no re-render of the whole modal.
setClipClock((c) => (c.now === now && c.duration === duration ? c : { now, duration }));
};
read();
const iv = window.setInterval(read, 200);
return () => window.clearInterval(iv);
}, [clipOpen]);
// The scissors that opened the panel gets the focus back when it closes.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const closeClip = () => {
refocusClip.current = true;
setClip(null);
};
useEffect(() => {
if (clipOpen || !refocusClip.current) return;
refocusClip.current = false;
clipButtonRef.current?.focus();
}, [clipOpen]);
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
* panel lives under the video, so a big view steps back to the card first. */
const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
const t = p.getCurrentTime() || 0;
const d = p.getDuration() || 0;
if (d <= 0) return;
if (maximized || isFullscreen) exitBigView();
setClipClock({ now: t, duration: d });
setClip((r) => mark(r, t, d));
};
const openClip = () => markClip((_r, t, d) => openAt(t, d));
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
@@ -601,6 +654,13 @@ export default function PlayerModal({
return;
}
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
else openClip();
},
markIn: () => markClip((r, t, d) => markIn(r ?? openAt(t, d), t, d)),
markOut: () =>
markClip((r, t, d) => markOut(r ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d)),
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
@@ -609,6 +669,12 @@ export default function PlayerModal({
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// The clip panel is the player's own layer: it closes before the player does.
if (clip) {
ev.preventDefault();
closeClip();
return;
}
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
@@ -746,12 +812,15 @@ export default function PlayerModal({
}`}
onClick={(e) => e.stopPropagation()}
>
{/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
@@ -838,6 +907,12 @@ export default function PlayerModal({
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{/* The clip panel lives under the video in the card: this steps out of the big view. */}
{playerError == null && (
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
<Scissors />
</IconButton>
)}
{!navigated && (
<Button
variant="overlay"
@@ -891,7 +966,11 @@ export default function PlayerModal({
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
{volumeUi === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
@@ -927,6 +1006,26 @@ export default function PlayerModal({
)}
</div>
{clip && !maximized && !isFullscreen && (
<ClipPanel
src={{ source: "youtube", ref: currentVideoId }}
clip={clip}
onChange={setClip}
duration={clipClock.duration}
current={clipClock.now}
onSeek={(time, final) => {
// A seek past what is buffered fetches from there: only once the drag settles.
playerRef.current?.seekTo?.(time, final);
setClipClock((c) => ({ ...c, now: time }));
}}
onPlayFrom={(time) => {
seekTo(time);
setClipClock((c) => ({ ...c, now: time }));
}}
onClose={closeClip}
/>
)}
</div>
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
description never makes the card taller than the video. */}
<div
@@ -1201,6 +1300,19 @@ export default function PlayerModal({
variant="outline"
size="md"
/>
{playerError == null && (
<IconButton
ref={clipButtonRef}
variant="outline"
size="md"
pressed={clipOpen}
onClick={() => (clip ? closeClip() : openClip())}
label={t("clip.open")}
title={`${t("clip.open")} · C`}
>
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
<IconButton
variant="outline-danger"
@@ -927,3 +927,60 @@ describe("the fullscreen button", () => {
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
});
// Clip mode: `[` / `]` mark at the playhead (opening the panel), the panel takes the control row's
// place, Escape closes the panel before it would close the player, and the scissors gets the focus
// back. Cut sends this item and the audio track that is playing.
describe("PlexPlayer — clip mode", () => {
const key = (k: string, init: KeyboardEventInit = {}) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }),
),
);
it("opens on [ with Start at the playhead, and Escape closes only the panel", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
expect(screen.queryByTestId("clip-panel")).toBeNull();
key("[");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"120",
);
// The seek bar and the control row make room for it.
expect(screen.queryByLabelText("plex.player.playPause")).toBeNull();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(screen.getByLabelText("plex.player.playPause")).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks End on ] typed through AltGr (the HU layout's ])", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 200);
key("]", { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"200",
);
});
it("cuts this item with the audio track that is playing", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
mount({ details: { a: item("a") } });
await playingAt("/media/a", 60);
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0]).toMatchObject({
source: "plex",
ref: "a",
start_s: 60,
end_s: 90,
audio_track: 0,
});
});
});
+89 -7
View File
@@ -29,6 +29,7 @@ import {
MoreHorizontal,
Pause,
Play,
Scissors,
Settings,
SkipBack,
SkipForward,
@@ -48,6 +49,8 @@ import {
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import ClipPanel from "./ClipPanel";
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
@@ -288,8 +291,31 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
"sync",
);
// keep the control bar up while a menu is open
const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings");
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
// next item starts without one (reset against the item it was opened on).
const [clip, setClip] = useState<ClipRange | null>(null);
const [clipFor, setClipFor] = useState(id);
if (clipFor !== id) {
setClipFor(id);
setClip(null);
}
const clipRef = useLatest(clip);
// The scissors that opened the panel is gone while it is open; closing it gives the focus back.
const clipButtonRef = useRef<HTMLButtonElement>(null);
const refocusClip = useRef(false);
const closeClip = useCallback(() => {
refocusClip.current = true;
setClip(null);
}, []);
useEffect(() => {
if (clip != null || !refocusClip.current) return;
refocusClip.current = false;
clipButtonRef.current?.focus();
}, [clip]);
// keep the control bar up while a menu — or the clip panel — is open
const menuOpenRef = useLatest(
panel === "more" || panel === "tracks" || panel === "settings" || clip != null,
);
const menuRef = useRef<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
@@ -1006,6 +1032,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
},
info: () => togglePanel("info"),
stats: () => setStatsOpen((v) => !v),
clip: () => {
if (clipRef.current) closeClip();
else setClip(openAt(absRef.current, durationRef.current));
},
markIn: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markIn(c ?? openAt(t, d), t, d));
},
markOut: () => {
const t = absRef.current;
const d = durationRef.current;
setClip((c) => markOut(c ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d));
},
back: (ev) => {
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
// Back button (shared cascade).
@@ -1018,11 +1058,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `back`'s cascade is
// what closes them; anything else layered above us takes the key.
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
const ownPanelOpen =
panelRef.current !== "none" || statsOpenRef.current || clipRef.current != null;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
back("key");
// The clip panel is the player's own too, and closes before the player does.
if (clipRef.current) closeClip();
else back("key");
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
@@ -1032,7 +1075,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
back("escape");
if (clipRef.current) closeClip();
else back("escape");
},
});
};
@@ -1063,6 +1107,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
fineSeekStepRef,
panelRef,
statsOpenRef,
clipRef,
closeClip,
absRef,
durationRef,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
@@ -1345,6 +1393,25 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
: "opacity-0 pointer-events-none"
}`}
>
{/* Clip mode: the clip panel takes the seek bar's and the control row's place (it has a
timeline of its own). Its seeks wait for the release: a seek may restart the transcode. */}
{clip ? (
<div className="overflow-hidden rounded-xl">
<ClipPanel
src={{ source: "plex", ref: id, audioTrack: audioOrd ?? 0 }}
clip={clip}
onChange={setClip}
duration={duration}
current={abs}
onSeek={(time, final) => {
if (final) seekTo(time);
}}
onPlayFrom={(time) => seekTo(time, true)}
onClose={closeClip}
/>
</div>
) : (
<>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. The track stays thin
inside a 2.75rem hit area (C3): a finger could not find the 6px bar, and `touch-none`
@@ -1492,8 +1559,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<span className="ml-1.5 text-white/45">
(
{
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
.time
clockParts(
new Date(now.getTime() + Math.max(0, duration - abs) * 1000),
prefs,
).time
}
)
</span>
@@ -1589,6 +1658,17 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
</>
)}
{/* Clip mode (`c`): mark a range of what is playing and cut it into a download. */}
{duration > 0 && (
<Ctrl
label={t("clip.open")}
onClick={() => setClip(openAt(abs, duration))}
buttonRef={clipButtonRef}
>
<Scissors className="w-5 h-5" />
</Ctrl>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
@@ -1648,6 +1728,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
)}
</div>
</div>
</>
)}
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
@@ -101,3 +101,26 @@ describe("VideoEditor — a split export that fails part-way", () => {
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
// 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", () => {
it("steps a focused cut with the arrows and stops it before its neighbours", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(cut.getAttribute("aria-valuenow")).toBe("50");
fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true });
expect(cut.getAttribute("aria-valuenow")).toBe("51");
fireEvent.keyDown(cut, { key: "End" });
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
});
});
+54 -112
View File
@@ -15,29 +15,10 @@ import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60);
return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
}
function parseTc(v: string): number | null {
v = v.trim();
if (!v) return null;
if (v.includes(":")) {
const [m, s] = v.split(":");
const mm = Number(m),
ss = Number(s);
if (!isFinite(mm) || !isFinite(ss)) return null;
return mm * 60 + ss;
}
const n = Number(v);
return isFinite(n) ? n : null;
}
import { RangeTrack } from "./ui/RangeTrack";
import { formatTimecode, parseTimecode } from "../lib/timecode";
type Seg = { id: number; start: number; end: number; keep: boolean };
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const MIN_SEG = 0.1;
@@ -90,6 +71,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
const sb = useQuery({
@@ -200,37 +182,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
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);
// The hover point, measured in the pointer handler (track-relative x and its time), so render never
// reads the track's geometry (a DOM read in render made the React Compiler skip the editor).
// --- timeline hover preview ---
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
const timeAt = useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
},
[duration],
);
useEffect(() => {
if (!drag) return;
const move = (e: PointerEvent) => {
const tt = timeAt(e.clientX);
if (drag.type === "seek") {
setCurrent(tt);
seek(tt);
} else moveBoundary(drag.bi, tt);
};
const up = () => setDrag(null);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [drag, timeAt, seek, moveBoundary]);
// --- crop drag ---
const overlayRef = useRef<HTMLDivElement>(null);
@@ -274,7 +229,6 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
}, []);
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`;
const tileStyle = (idx: number): React.CSSProperties => {
const cols = strip?.cols ?? 12;
const rows = strip?.rows ?? 1;
@@ -468,23 +422,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
<div className="relative">
<div
ref={trackRef}
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
onPointerDown={(e) => {
setDrag({ type: "seek" });
seek(timeAt(e.clientX));
<div ref={trackRef}>
<RangeTrack
label={t("editor.timeline")}
duration={duration}
playhead={current}
onSeek={(time) => {
setCurrent(time);
seek(time);
}}
onPointerMove={(e) =>
setHover({
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
time: timeAt(e.clientX),
})
}
onPointerLeave={() => setHover(null)}
>
{/* aspect-correct filmstrip */}
{stripCells > 0 && (
onHover={setHover}
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
background={
stripCells > 0 && (
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: stripCells }).map((_, i) => (
<div
@@ -496,30 +446,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
/>
))}
</div>
)}
{/* segment tint: dropped = dimmed + hatched */}
{segments.map((s) => (
<div
key={s.id}
className={clsx(
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
)
}
regions={segments.map((s) => ({
key: s.id,
start: s.start,
end: s.end,
// segment tint: dropped = dimmed + hatched
className: clsx(
s.end < duration && "border-r border-black/40",
s.keep ? "bg-accent/10" : "bg-black/60",
s.id === selId && "ring-2 ring-inset ring-white/70",
)}
style={{
left: pct(s.start),
width: pct(s.end - s.start),
...(s.keep
? {}
),
style: s.keep
? undefined
: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
}),
}}
onPointerDown={() => setSelId(s.id)}
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
>
{/* keep/drop toggle */}
},
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
onPointerDown: () => setSelId(s.id),
children: (
// keep/drop toggle
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
@@ -534,38 +482,32 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
>
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Pressable>
</div>
))}
{/* draggable boundary markers */}
{boundaries.map((b, bi) => (
<div
key={bi}
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
style={{ left: pct(b) }}
>
<div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
onPointerDown={(e) => {
e.stopPropagation();
setDrag({ type: "boundary", bi });
}}
/>
),
}))}
// The cuts between segments: dragged, or stepped with the arrows once focused.
handles={boundaries.map((b, bi) => ({
id: `cut-${bi}`,
label: t("editor.cutN", { n: bi + 1 }),
time: b,
min: segments[bi]!.start + MIN_SEG,
max: segments[bi + 1]!.end - MIN_SEG,
valueText: tc(b),
step: 0.1,
bigStep: 1,
onChange: (time: number) => moveBoundary(bi, time),
adornment: (
<Pressable
onPointerDown={(e) => {
e.stopPropagation();
deleteBoundary(bi);
}}
className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
className="absolute top-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
title={t("editor.removeCut")}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
{/* playhead */}
<div
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
style={{ left: pct(current) }}
),
}))}
/>
</div>
@@ -612,7 +554,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
key={`s-${sel.id}-${sel.start}`}
disabled={selIdx === 0}
onBlur={(e) => {
const v = parseTc(e.target.value);
const v = parseTimecode(e.target.value);
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
}}
/>
@@ -626,7 +568,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
key={`e-${sel.id}-${sel.end}`}
disabled={selIdx === segments.length - 1}
onBlur={(e) => {
const v = parseTc(e.target.value);
const v = parseTimecode(e.target.value);
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
}}
/>
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { type ReactNode, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton";
@@ -96,11 +96,13 @@ export function Ctrl({
onClick,
disabled,
align = "center",
buttonRef,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
buttonRef?: Ref<HTMLButtonElement>;
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
align?: "start" | "center" | "end";
children: ReactNode;
@@ -114,6 +116,7 @@ export function Ctrl({
return (
<div className="relative flex group/ctrl">
<IconButton
ref={buttonRef}
variant="overlay"
size="player"
round
+2 -2
View File
@@ -36,10 +36,10 @@ 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 `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
//
// The shared time track: a handle is a slider the keyboard steps (and the step stops at its range),
// a drag reports its moves as not-final and its release as final, a press on the bare track seeks,
// and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack";
afterEach(cleanup);
beforeAll(() => {
// jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads.
class PointerEventStub extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
});
function box(el: Element, left = 0, width = 1000) {
el.getBoundingClientRect = () =>
({
left,
width,
top: 0,
height: 40,
right: left + width,
bottom: 40,
x: left,
y: 0,
}) as DOMRect;
}
function handle(over: Partial<TrackHandle> = {}): TrackHandle {
return {
id: "in",
time: 50,
min: 10,
max: 90,
label: "In",
valueText: "0:50.0",
step: 0.1,
bigStep: 1,
onChange: vi.fn(),
...over,
};
}
describe("RangeTrack", () => {
it("draws a handle as a slider with its time as the value", () => {
render(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
const s = screen.getByRole("slider", { name: "In" });
expect(s.getAttribute("aria-valuenow")).toBe("50");
expect(s.getAttribute("aria-valuetext")).toBe("0:50.0");
expect(s.style.left).toBe("50%");
});
it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
const right = new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
});
s.dispatchEvent(right);
expect(h.onChange).toHaveBeenLastCalledWith(50.1, true);
expect(right.defaultPrevented).toBe(true);
fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(49, true);
});
it("stops a step at the handle's range", () => {
const h = handle({ time: 89.95 });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
fireEvent.keyDown(s, { key: "Home" });
expect(h.onChange).toHaveBeenLastCalledWith(10, true);
});
it("leaves a modified arrow to the page", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), {
key: "ArrowRight",
ctrlKey: true,
});
expect(h.onChange).not.toHaveBeenCalled();
});
it("reports a drag's moves as not final and its release as final, clamped to the range", () => {
const h = handle();
const onSeek = vi.fn();
render(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, false);
fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
// The handle's press is not a seek of the track under it.
expect(onSeek).not.toHaveBeenCalled();
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(50, false);
fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, false);
fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, true);
});
it("maps time through its window, and draws nothing that falls outside it", () => {
const onSeek = vi.fn();
render(
<RangeTrack
duration={3600}
view={{ from: 600, to: 700 }}
label="Zoom"
playhead={50}
onSeek={onSeek}
handles={[
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
]}
/>,
);
const track = screen.getByRole("group", { name: "Zoom" });
box(track);
expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%");
expect(screen.queryByRole("slider", { name: "Out" })).toBeNull();
expect(screen.queryByTestId("range-playhead")).toBeNull();
fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(610, false);
});
});
describe("RulerTicks", () => {
it("picks a step that keeps the window to a handful of ticks", () => {
const { container } = render(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
});
});
+270
View File
@@ -0,0 +1,270 @@
import {
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
//
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
// editors that drew the hit area as thin as the bar were the ones people missed.
//
// Every move reports `final`: false while a pointer drags, true on release or a key. A player whose
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
export interface RangeWindow {
from: number;
to: number;
}
export interface TrackHandle {
id: string;
time: number;
/** The range the handle may move in (seconds); a move past it stops at the edge. */
min: number;
max: number;
/** Accessible name ("In", "Cut 2"). */
label: string;
/** The time as read out (aria-valuetext), in the caller's own format. */
valueText: string;
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
step: number;
bigStep: number;
onChange: (time: number, final: boolean) => void;
/** The drawn bar's look (its colour); the hit area around it is the track's. */
barClassName?: string;
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
adornment?: ReactNode;
}
interface TrackRegion {
key: string | number;
start: number;
end: number;
className?: string;
style?: CSSProperties;
title?: string;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
children?: ReactNode;
}
interface Props {
/** The media's length; with no `view` the track shows all of it. */
duration: number;
view?: RangeWindow;
playhead?: number | null;
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
onSeek?: (time: number, final: boolean) => void;
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
onHover?: (h: { x: number; time: number } | null) => void;
handles?: readonly TrackHandle[];
regions?: readonly TrackRegion[];
background?: ReactNode;
/** The track's box: its height, surface and edge. */
className?: string;
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** The time under `clientX` on the track that holds `el`. */
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
const track = el.closest("[data-range-track]") ?? el;
const r = track.getBoundingClientRect();
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
return win.from + frac * (win.to - win.from);
}
export function RangeTrack({
duration,
view,
playhead,
onSeek,
onHover,
handles = [],
regions = [],
background,
className,
label,
playheadClassName = "bg-white",
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const span = Math.max(win.to - win.from, 1e-6);
const frac = (t: number) => (t - win.from) / span;
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<string | null>(null);
const [scrubbing, setScrubbing] = useState(false);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
}
};
return (
<div
data-range-track=""
role="group"
aria-label={label}
className={clsx("relative touch-none select-none overflow-hidden", className)}
onPointerDown={(e) => {
if (!onSeek || e.button !== 0) return;
capture(e);
setScrubbing(true);
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerMove={(e) => {
const time = timeAtPointer(e.currentTarget, e.clientX, win);
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
if (scrubbing) onSeek?.(time, false);
}}
onPointerUp={(e) => {
if (!scrubbing) return;
setScrubbing(false);
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setScrubbing(false)}
onPointerLeave={() => onHover?.(null)}
>
{background}
{regions.map((r) => {
const a = clamp(frac(r.start), 0, 1);
const b = clamp(frac(r.end), 0, 1);
if (b <= a && r.end > r.start) return null; // wholly outside the window
return (
<div
key={r.key}
className={clsx("absolute inset-y-0", r.className)}
style={{ left: pct(a), width: pct(b - a), ...r.style }}
title={r.title}
onPointerDown={r.onPointerDown}
>
{r.children}
</div>
);
})}
{playhead != null && playhead >= win.from && playhead <= win.to && (
<div
data-testid="range-playhead"
className={clsx(
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
playheadClassName,
)}
style={{ left: pct(frac(playhead)) }}
/>
)}
{handles.map((h) => {
const f = frac(h.time);
if (f < 0 || f > 1) return null;
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? h.bigStep : h.step;
const to: Record<string, number> = {
ArrowLeft: h.time - big,
ArrowDown: h.time - big,
ArrowRight: h.time + big,
ArrowUp: h.time + big,
PageDown: h.time - h.bigStep,
PageUp: h.time + h.bigStep,
Home: h.min,
End: h.max,
};
const next = to[e.key];
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
// skips a handled key).
e.preventDefault();
e.stopPropagation();
move(next, true);
};
return (
<div
key={h.id}
role="slider"
tabIndex={0}
aria-label={h.label}
aria-valuemin={Math.round(h.min * 10) / 10}
aria-valuemax={Math.round(h.max * 10) / 10}
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow.
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
capture(e);
setDragging(h.id);
}}
onPointerMove={(e) => {
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerUp={(e) => {
if (dragging !== h.id) return;
e.stopPropagation();
setDragging(null);
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setDragging(null)}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
{h.adornment}
</div>
);
})}
</div>
);
}
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
* each labelled by `format`. */
export function RulerTicks({
view,
format,
max = 8,
className,
}: {
view: RangeWindow;
format: (t: number) => string;
max?: number;
className?: string;
}) {
const span = Math.max(view.to - view.from, 1e-6);
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
const ticks: number[] = [];
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
return (
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
{ticks.map((t) => (
<div
key={t}
className="absolute bottom-0 h-2 border-l border-current"
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
>
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
{format(t)}
</span>
</div>
))}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"title": "Clip",
"open": "Make a clip",
"close": "Close the clip panel",
"overview": "Whole video",
"strip": "Clip range",
"in": "Start",
"out": "End",
"inHere": "Start at the current position",
"outHere": "End at the current position",
"earlier": "{{label}}: a tenth of a second earlier",
"later": "{{label}}: a tenth of a second later",
"length": "Length {{length}}",
"loop": "Play selection",
"name": "Clip name",
"namePlaceholder": "Clip name (optional)",
"cut": "Cut clip",
"queued": "The clip is being made — it will be in your downloads",
"view": "Downloads",
"failed": "Couldn’t make the clip"
}
+3 -1
View File
@@ -39,5 +39,7 @@
"droppedSeg": "Dropped segment",
"removeCut": "Remove cut",
"keptSummary": "{{n}} kept · {{dur}}",
"createJoin": "Create joined clip"
"createJoin": "Create joined clip",
"timeline": "Timeline",
"cutN": "Cut {{n}}"
}
+3 -1
View File
@@ -38,5 +38,7 @@
"cap": {
"space": "Space",
"menu": "Menu"
}
},
"clip": "Clip mode",
"clipMark": "Mark the clip’s start / end here"
}
+21
View File
@@ -0,0 +1,21 @@
{
"title": "Klip",
"open": "Klip készítése",
"close": "Klippanel bezárása",
"overview": "A teljes videó",
"strip": "A klip tartománya",
"in": "Eleje",
"out": "Vége",
"inHere": "Eleje a mostani pozíciónál",
"outHere": "Vége a mostani pozíciónál",
"earlier": "{{label}}: egy tized másodperccel korábban",
"later": "{{label}}: egy tized másodperccel később",
"length": "Hossz {{length}}",
"loop": "Kijelölés lejátszása",
"name": "A klip neve",
"namePlaceholder": "A klip neve (nem kötelező)",
"cut": "Klip vágása",
"queued": "Készül a klip — a letöltéseid közé kerül",
"view": "Letöltések",
"failed": "Nem sikerült elkészíteni a klipet"
}
+3 -1
View File
@@ -39,5 +39,7 @@
"droppedSeg": "Eldobott szegmens",
"removeCut": "Vágás törlése",
"keptSummary": "{{n}} megtartva · {{dur}}",
"createJoin": "Összefűzött klip"
"createJoin": "Összefűzött klip",
"timeline": "Idővonal",
"cutN": "{{n}}. vágás"
}
+3 -1
View File
@@ -38,5 +38,7 @@
"cap": {
"space": "Szóköz",
"menu": "Menü"
}
},
"clip": "Klip mód",
"clipMark": "A klip eleje / vége itt"
}
+16 -2
View File
@@ -142,6 +142,18 @@
border-color: var(--edge-strong);
}
/* A form on the always-dark player chrome (the clip panel's fields): the neutral tokens the field
primitives read are re-pointed to white-on-chrome for this subtree, so a light theme's dark text
never lands on the dark bar. The accent stays the theme's. */
@utility media-scope {
--fg: #ffffff;
--muted: rgb(255 255 255 / 0.62);
--bg: var(--media-chrome);
--border: rgb(255 255 255 / 0.16);
--edge-strong: rgb(255 255 255 / 0.4);
color: var(--fg);
}
/* The reading measure for prose (a synopsis, a biography, a channel's description): ~65 characters
a line, inside the 45–75 a reader follows back to the next line's start. A full-width page ran
one to 110 (owner: "shorter lines are easier to follow"). Only where the container can
@@ -280,6 +292,7 @@
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
[role="slider"]:focus-visible,
summary:focus-visible {
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
outline: 2px solid var(--accent);
@@ -1191,9 +1204,10 @@ html[data-theme="light"] {
}
/* …and it only fills the viewport while no ancestor has a filter: a `backdrop-filter` makes that
ancestor the fixed stage's containing block. The modal's card is a blurred `.glass` panel,
so while the stage is maximised the card drops its blur — the stage covers the card anyway. `html `
so while the stage is maximised the card drops its blur — the stage covers the card anyway. Any
depth: the stage sits in a wrapper with the clip panel. `html `
outranks `html[data-theme="dark"] .glass`, which sets the blur again (lib/playerStage.contract.test.ts). */
html .glass:has(> .player-stage--max) {
html .glass:has(.player-stage--max) {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
+13
View File
@@ -51,6 +51,15 @@ export type DownloadJobKind = "download" | "edit" | "upload" | "clip";
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
export interface ClipRequest {
source: "youtube" | "plex";
ref: string;
start_s: number;
end_s: number;
audio_track?: number;
display_name?: string;
}
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
@@ -226,6 +235,10 @@ export const downloadsApi = {
display_name?: string;
}): Promise<DownloadJob> =>
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
// An ad-hoc clip marked in a player: a YouTube video's or a Plex item's range, cut into a new
// download of its own (`audio_track`: the Plex audio track that was playing).
enqueueClip: (body: ClipRequest): Promise<DownloadJob> =>
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_CLIP_S,
MAX_CLIP_S,
handleBounds,
markIn,
markOut,
openAt,
zoomWindow,
} from "./clipRange";
const D = 3600;
describe("clipRange", () => {
it("opens a default-length range at the playhead, shorter at the item's end", () => {
expect(openAt(100, D)).toEqual({ start: 100, end: 100 + DEFAULT_CLIP_S });
expect(openAt(3590, D)).toEqual({ start: 3590, end: 3600 });
expect(openAt(3600, D)).toEqual({ start: 3599, end: 3600 });
});
it("moves In alone while the range stays one the server takes", () => {
expect(markIn({ start: 100, end: 130 }, 90, D)).toEqual({ start: 90, end: 130 });
});
it("carries Out along when In passes it, and keeps the cap when In goes too far back", () => {
expect(markIn({ start: 100, end: 130 }, 200, D)).toEqual({ start: 200, end: 230 });
expect(markIn({ start: 1000, end: 1030 }, 100, D)).toEqual({
start: 100,
end: 100 + MAX_CLIP_S,
});
});
it("carries In along when Out goes before it or too far ahead", () => {
expect(markOut({ start: 100, end: 130 }, 150, D)).toEqual({ start: 100, end: 150 });
expect(markOut({ start: 100, end: 130 }, 50, D)).toEqual({ start: 20, end: 50 });
expect(markOut({ start: 100, end: 130 }, 1000, D)).toEqual({ start: 400, end: 1000 });
});
it("every marked range is at least a second and at most the cap", () => {
for (const [r, t] of [
[{ start: 0, end: 30 }, 0],
[{ start: 0, end: 30 }, 3600],
[{ start: 3000, end: 3600 }, 0],
] as const) {
for (const next of [markIn(r, t, D), markOut(r, t, D)]) {
expect(next.end - next.start).toBeGreaterThanOrEqual(1);
expect(next.end - next.start).toBeLessThanOrEqual(MAX_CLIP_S);
expect(next.start).toBeGreaterThanOrEqual(0);
expect(next.end).toBeLessThanOrEqual(D);
}
}
});
it("bounds each handle so a drag never breaks the range", () => {
expect(handleBounds({ start: 100, end: 130 }, D)).toEqual({
in: { min: 0, max: 129 },
out: { min: 101, max: 700 },
});
});
it("zooms to the range with room either side, inside the item", () => {
expect(zoomWindow({ start: 100, end: 140 }, D)).toEqual({ from: 90, to: 150 });
expect(zoomWindow({ start: 2, end: 10 }, D)).toEqual({ from: 0, to: 15 });
});
});
+55
View File
@@ -0,0 +1,55 @@
// The range an ad-hoc clip is cut from, marked in a player: the rules the clip panel's handles, its
// In / Out fields and the players' `[` / `]` keys all go by, so every way of marking keeps a range the
// server takes (backend downloads/clip.py: at least MIN_CLIP_S, at most MAX_CLIP_S — the owner's cap).
const MIN_CLIP_S = 1;
export const MAX_CLIP_S = 600;
/** What a fresh mark spans before the second end is set: a moment, not the cap. */
export const DEFAULT_CLIP_S = 30;
export interface ClipRange {
start: number;
end: number;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** A range opened at `t`: from there, DEFAULT_CLIP_S long (shorter at the item's end). */
export function openAt(t: number, duration: number): ClipRange {
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
return { start, end: Math.min(duration, start + DEFAULT_CLIP_S) };
}
/** In moved to `t`. Out stays where it is while the range stays one the server takes; otherwise it
* follows (a fresh DEFAULT_CLIP_S after an In past it, the cap after an In too far before it). */
export function markIn(r: ClipRange, t: number, duration: number): ClipRange {
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
let end = r.end;
if (end - start < MIN_CLIP_S) end = Math.min(duration, start + DEFAULT_CLIP_S);
else if (end - start > MAX_CLIP_S) end = start + MAX_CLIP_S;
return { start, end };
}
/** Out moved to `t`; In follows the same way when the range would stop being one. */
export function markOut(r: ClipRange, t: number, duration: number): ClipRange {
const end = clamp(t, Math.min(MIN_CLIP_S, duration), duration);
let start = r.start;
if (end - start < MIN_CLIP_S) start = Math.max(0, end - DEFAULT_CLIP_S);
else if (end - start > MAX_CLIP_S) start = end - MAX_CLIP_S;
return { start, end };
}
/** Where each end may be dragged to without the other moving. */
export function handleBounds(r: ClipRange, duration: number) {
return {
in: { min: Math.max(0, r.end - MAX_CLIP_S), max: r.end - MIN_CLIP_S },
out: { min: r.start + MIN_CLIP_S, max: Math.min(duration, r.start + MAX_CLIP_S) },
};
}
/** The zoomed strip's window: the range with a margin either side (a quarter of it, at least 5 s),
* so both ends sit inside with room to move them out. */
export function zoomWindow(r: ClipRange, duration: number): { from: number; to: number } {
const pad = Math.max(5, (r.end - r.start) * 0.25);
return { from: Math.max(0, r.start - pad), to: Math.min(duration, r.end + pad) };
}
+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);
+17
View File
@@ -166,6 +166,11 @@ export const SHORTCUTS = [
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], labelKey: "keys.volume" },
// Clip mode (the clip panel): `c` opens / closes it, `[` and `]` mark its ends at the playhead
// (opening it when it is shut).
{ id: "clip", scope: "ytPlayer", keys: ["c"], labelKey: "keys.clip" },
{ id: "markIn", scope: "ytPlayer", keys: ["["], labelKey: "keys.clipMark" },
{ id: "markOut", scope: "ytPlayer", keys: ["]"], labelKey: "keys.clipMark" },
{
id: "close",
scope: "ytPlayer",
@@ -202,6 +207,9 @@ export const SHORTCUTS = [
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
{ id: "clip", scope: "plexPlayer", keys: ["c"], labelKey: "keys.clip" },
{ id: "markIn", scope: "plexPlayer", keys: ["["], labelKey: "keys.clipMark" },
{ id: "markOut", scope: "plexPlayer", keys: ["]"], labelKey: "keys.clipMark" },
// Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to
// anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey
// keeps them a single "⌫ Esc Stop & go back" row in the sheet.
@@ -266,6 +274,11 @@ export function isActivationTarget(e: KeyboardEvent): boolean {
return tag === "button" || tag === "a";
}
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
function isSymbol(name: string): boolean {
return [...name].length === 1 && !/[\p{L}\p{N}\s]/u.test(name);
}
function keyMatches(e: KeyboardEvent, name: string): boolean {
if (name === "Space") return e.key === " " || e.code === "Space";
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
@@ -280,6 +293,10 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+");
const key = parts[parts.length - 1] ?? "";
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
// A symbol the layout types through AltGr (the HU `[` is AltGr+F, Ctrl+Alt on Windows) is that
// symbol: a bare-symbol row answers it. A letter or a Mod row never does — AltGr+K is not Ctrl+K.
if (mods.length === 0 && isSymbol(key) && e.getModifierState("AltGraph"))
return keyMatches(e, key);
if ((e.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
if (e.shiftKey !== mods.includes("shift")) return false;
if (e.altKey !== mods.includes("alt")) return false;
@@ -11,7 +11,7 @@ import { expect, it } from "vitest";
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
it("a maximised player stage takes the blur off the glass card it sits in", () => {
const at = css.indexOf("html .glass:has(> .player-stage--max)");
const at = css.indexOf("html .glass:has(.player-stage--max)");
expect(at, "the escape rule").toBeGreaterThan(css.indexOf('html[data-theme="dark"] .glass'));
const body = css.slice(at, css.indexOf("}", at));
expect(body).toMatch(/(^|[\s;{])backdrop-filter:\s*none/);
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatTimecode, parseTimecode } from "./timecode";
describe("formatTimecode", () => {
it("is m:ss.d below an hour and h:mm:ss.d from one", () => {
expect(formatTimecode(12.44)).toBe("0:12.4");
expect(formatTimecode(75)).toBe("1:15.0");
expect(formatTimecode(3723.4)).toBe("1:02:03.4");
});
it("shows hours when the item reaches one, so an item's fields agree", () => {
expect(formatTimecode(65, true)).toBe("0:01:05.0");
});
it("carries a rounded tenth into the next second, never a 60.0", () => {
expect(formatTimecode(59.96)).toBe("1:00.0");
});
it("reads a bad clock as zero", () => {
expect(formatTimecode(NaN)).toBe("0:00.0");
expect(formatTimecode(-3)).toBe("0:00.0");
});
});
describe("parseTimecode", () => {
it("reads every shape it writes, and bare seconds", () => {
expect(parseTimecode("0:12.4")).toBeCloseTo(12.4);
expect(parseTimecode("1:02:03.4")).toBeCloseTo(3723.4);
expect(parseTimecode(" 90.5 ")).toBeCloseTo(90.5);
});
it("is null for what is not a timecode", () => {
for (const v of ["", "abc", "1::2", "1:2:3:4", "-5", "1:-2"])
expect(parseTimecode(v)).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
// An editing timecode: to the tenth of a second, the precision a cut is marked at (the editor's
// segment fields, the clip panel's In / Out). Hours appear only when the item reaches an hour, so a
// short's "0:12.4" stays short and a film reads "1:02:03.4". `formatDuration` is the whole-second
// clock for a card or a player bar; this one is for a field someone types a cut into.
/** `s` as `m:ss.d`, or `h:mm:ss.d` when `hours` (pass `duration >= 3600` so one item's fields agree). */
export function formatTimecode(s: number, hours = false): string {
if (!isFinite(s) || s < 0) s = 0;
const tenths = Math.round(s * 10);
const h = Math.floor(tenths / 36000);
const m = Math.floor((tenths % 36000) / 600);
const sec = ((tenths % 600) / 10).toFixed(1).padStart(4, "0");
return hours || h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
/** A typed timecode back to seconds: `h:mm:ss.d`, `m:ss.d` or bare seconds. Null when it is not one. */
export function parseTimecode(v: string): number | null {
const parts = v.trim().split(":");
if (parts.length > 3 || parts.some((p) => p.trim() === "")) return null;
let total = 0;
for (const p of parts) {
const n = Number(p);
if (!isFinite(n) || n < 0) return null;
total = total * 60 + n;
}
return total;
}