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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user