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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"cap": {
|
||||
"space": "Space",
|
||||
"menu": "Menu"
|
||||
}
|
||||
},
|
||||
"clip": "Clip mode",
|
||||
"clipMark": "Mark the clip’s start / end here"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"cap": {
|
||||
"space": "Szóköz",
|
||||
"menu": "Menü"
|
||||
}
|
||||
},
|
||||
"clip": "Klip mód",
|
||||
"clipMark": "A klip eleje / vége itt"
|
||||
}
|
||||
|
||||
+16
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user