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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user