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
|
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
|
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]
|
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)
|
## 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 | 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-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-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.next" })).toHaveLength(1);
|
||||||
expect(screen.getAllByRole("button", { name: "player.previous" })).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/);
|
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 (
|
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
|
||||||
this: HTMLElement,
|
this: HTMLElement,
|
||||||
) {
|
) {
|
||||||
return this.querySelector(":scope > .player-stage") ? 630 : 0;
|
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
|
||||||
});
|
});
|
||||||
const A = vid("A", 0);
|
const A = vid("A", 0);
|
||||||
const B = vid("B", 0);
|
const B = vid("B", 0);
|
||||||
renderModal(
|
renderModal(
|
||||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
<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))");
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
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");
|
const info = screen.getByTestId("player-info");
|
||||||
// The card is a row: the video, then the panel holding the title and the channel.
|
// The card is a row: the video, then the panel holding the title and the channel.
|
||||||
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const row = () =>
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||||
expect(screen.queryByTestId("player-info")).toBeNull();
|
expect(screen.queryByTestId("player-info")).toBeNull();
|
||||||
const strip = screen.getByTestId("player-strip");
|
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={() => {}} />,
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
);
|
);
|
||||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
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, "scrollHeight", { value: 900, configurable: true });
|
||||||
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
||||||
const spin = (el: Element) => {
|
const spin = (el: Element) => {
|
||||||
@@ -818,3 +825,56 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
|||||||
expect(maximised(false)).toBe(1);
|
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,
|
Minimize2,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
@@ -50,6 +51,8 @@ import {
|
|||||||
} from "../lib/layerStack";
|
} from "../lib/layerStack";
|
||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
import DownloadButton from "./DownloadButton";
|
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 { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import type { YTPlayer } from "../lib/youtube";
|
import type { YTPlayer } from "../lib/youtube";
|
||||||
import {
|
import {
|
||||||
@@ -338,6 +341,56 @@ export default function PlayerModal({
|
|||||||
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||||||
const currentIdRef = useRef(active.id);
|
const currentIdRef = useRef(active.id);
|
||||||
const navigated = currentVideoId !== 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).
|
// 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);
|
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.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(e, "ytPlayer", {
|
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) => {
|
close: (ev) => {
|
||||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
// 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
|
// 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.
|
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||||
if (escapeHandled(ev)) return;
|
if (escapeHandled(ev)) return;
|
||||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) 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.
|
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||||
if (fullscreenElement()) return;
|
if (fullscreenElement()) return;
|
||||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
// 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()}
|
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
|
<div
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||||
data-wheel-volume=""
|
data-wheel-volume=""
|
||||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
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" : ""}`}
|
} ${maximized ? "player-stage--max" : ""}`}
|
||||||
>
|
>
|
||||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||||
@@ -838,6 +907,12 @@ export default function PlayerModal({
|
|||||||
{!navigated && (
|
{!navigated && (
|
||||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
<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 && (
|
{!navigated && (
|
||||||
<Button
|
<Button
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
@@ -891,7 +966,11 @@ export default function PlayerModal({
|
|||||||
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||||
{volumeUi != null && (
|
{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">
|
<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-1.5 w-28 overflow-hidden rounded-full bg-white/25">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-white transition-all"
|
className="h-full rounded-full bg-white transition-all"
|
||||||
@@ -927,6 +1006,26 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{/* 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. */}
|
description never makes the card taller than the video. */}
|
||||||
<div
|
<div
|
||||||
@@ -1201,6 +1300,19 @@ export default function PlayerModal({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
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 && (
|
{onRemoveFromList && (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline-danger"
|
variant="outline-danger"
|
||||||
|
|||||||
@@ -927,3 +927,60 @@ describe("the fullscreen button", () => {
|
|||||||
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
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,
|
MoreHorizontal,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
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 { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import { createFragMeter } from "../lib/plexStats";
|
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">(
|
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
// keep the control bar up while a menu is open
|
// Clip mode: the range being marked, or null. Its panel takes the control band's place, and a
|
||||||
const menuOpenRef = useLatest(panel === "more" || panel === "tracks" || panel === "settings");
|
// 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 menuRef = useRef<HTMLDivElement>(null);
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
// 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"),
|
info: () => togglePanel("info"),
|
||||||
stats: () => setStatsOpen((v) => !v),
|
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) => {
|
back: (ev) => {
|
||||||
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||||||
// Back button (shared cascade).
|
// 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).
|
// 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
|
// 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.
|
// 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))
|
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||||||
return;
|
return;
|
||||||
ev.preventDefault();
|
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) => {
|
escape: (ev) => {
|
||||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
// 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.
|
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||||
if (escapeHandled(ev)) return;
|
if (escapeHandled(ev)) return;
|
||||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) 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,
|
fineSeekStepRef,
|
||||||
panelRef,
|
panelRef,
|
||||||
statsOpenRef,
|
statsOpenRef,
|
||||||
|
clipRef,
|
||||||
|
closeClip,
|
||||||
|
absRef,
|
||||||
|
durationRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
|
// 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"
|
: "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
|
{/* 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
|
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`
|
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">
|
<span className="ml-1.5 text-white/45">
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs)
|
clockParts(
|
||||||
.time
|
new Date(now.getTime() + Math.max(0, duration - abs) * 1000),
|
||||||
|
prefs,
|
||||||
|
).time
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</span>
|
</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,
|
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
||||||
so the ⋯ also closes it. */}
|
so the ⋯ also closes it. */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -1648,6 +1728,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
|
{/* 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)");
|
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 { Pressable } from "./ui/Pressable";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { RangeTrack } from "./ui/RangeTrack";
|
||||||
// mm:ss.d timecode <-> seconds.
|
import { formatTimecode, parseTimecode } from "../lib/timecode";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
|
||||||
|
|
||||||
const MIN_SEG = 0.1;
|
const MIN_SEG = 0.1;
|
||||||
|
|
||||||
@@ -90,6 +71,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||||
|
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
|
||||||
const aspect =
|
const aspect =
|
||||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
||||||
const sb = useQuery({
|
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)),
|
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- timeline drag (seek / boundary) + hover preview ---
|
// --- timeline hover preview ---
|
||||||
const [drag, setDrag] = useState<Drag | null>(null);
|
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
|
||||||
// The hover point, measured in the pointer handler (track-relative x and its time), so render never
|
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
|
||||||
// reads the track's geometry (a DOM read in render made the React Compiler skip the editor).
|
|
||||||
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
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 ---
|
// --- crop drag ---
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
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) ---
|
// --- 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 tileStyle = (idx: number): React.CSSProperties => {
|
||||||
const cols = strip?.cols ?? 12;
|
const cols = strip?.cols ?? 12;
|
||||||
const rows = strip?.rows ?? 1;
|
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 */}
|
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div ref={trackRef}>
|
||||||
ref={trackRef}
|
<RangeTrack
|
||||||
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
label={t("editor.timeline")}
|
||||||
onPointerDown={(e) => {
|
duration={duration}
|
||||||
setDrag({ type: "seek" });
|
playhead={current}
|
||||||
seek(timeAt(e.clientX));
|
onSeek={(time) => {
|
||||||
|
setCurrent(time);
|
||||||
|
seek(time);
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) =>
|
onHover={setHover}
|
||||||
setHover({
|
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
|
||||||
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
|
background={
|
||||||
time: timeAt(e.clientX),
|
stripCells > 0 && (
|
||||||
})
|
|
||||||
}
|
|
||||||
onPointerLeave={() => setHover(null)}
|
|
||||||
>
|
|
||||||
{/* aspect-correct filmstrip */}
|
|
||||||
{stripCells > 0 && (
|
|
||||||
<div className="absolute inset-0 flex pointer-events-none">
|
<div className="absolute inset-0 flex pointer-events-none">
|
||||||
{Array.from({ length: stripCells }).map((_, i) => (
|
{Array.from({ length: stripCells }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -496,30 +446,28 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
{/* segment tint: dropped = dimmed + hatched */}
|
}
|
||||||
{segments.map((s) => (
|
regions={segments.map((s) => ({
|
||||||
<div
|
key: s.id,
|
||||||
key={s.id}
|
start: s.start,
|
||||||
className={clsx(
|
end: s.end,
|
||||||
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
|
// segment tint: dropped = dimmed + hatched
|
||||||
|
className: clsx(
|
||||||
|
s.end < duration && "border-r border-black/40",
|
||||||
s.keep ? "bg-accent/10" : "bg-black/60",
|
s.keep ? "bg-accent/10" : "bg-black/60",
|
||||||
s.id === selId && "ring-2 ring-inset ring-white/70",
|
s.id === selId && "ring-2 ring-inset ring-white/70",
|
||||||
)}
|
),
|
||||||
style={{
|
style: s.keep
|
||||||
left: pct(s.start),
|
? undefined
|
||||||
width: pct(s.end - s.start),
|
|
||||||
...(s.keep
|
|
||||||
? {}
|
|
||||||
: {
|
: {
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
||||||
}),
|
},
|
||||||
}}
|
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
||||||
onPointerDown={() => setSelId(s.id)}
|
onPointerDown: () => setSelId(s.id),
|
||||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
children: (
|
||||||
>
|
// keep/drop toggle
|
||||||
{/* keep/drop toggle */}
|
|
||||||
<Pressable
|
<Pressable
|
||||||
aria-pressed={s.keep}
|
aria-pressed={s.keep}
|
||||||
onPointerDown={(e) => {
|
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" />}
|
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</div>
|
),
|
||||||
))}
|
}))}
|
||||||
{/* draggable boundary markers */}
|
// The cuts between segments: dragged, or stepped with the arrows once focused.
|
||||||
{boundaries.map((b, bi) => (
|
handles={boundaries.map((b, bi) => ({
|
||||||
<div
|
id: `cut-${bi}`,
|
||||||
key={bi}
|
label: t("editor.cutN", { n: bi + 1 }),
|
||||||
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base"
|
time: b,
|
||||||
style={{ left: pct(b) }}
|
min: segments[bi]!.start + MIN_SEG,
|
||||||
>
|
max: segments[bi + 1]!.end - MIN_SEG,
|
||||||
<div
|
valueText: tc(b),
|
||||||
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm"
|
step: 0.1,
|
||||||
onPointerDown={(e) => {
|
bigStep: 1,
|
||||||
e.stopPropagation();
|
onChange: (time: number) => moveBoundary(bi, time),
|
||||||
setDrag({ type: "boundary", bi });
|
adornment: (
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteBoundary(bi);
|
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")}
|
title={t("editor.removeCut")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</div>
|
),
|
||||||
))}
|
}))}
|
||||||
{/* playhead */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
|
|
||||||
style={{ left: pct(current) }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -612,7 +554,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
key={`s-${sel.id}-${sel.start}`}
|
key={`s-${sel.id}-${sel.start}`}
|
||||||
disabled={selIdx === 0}
|
disabled={selIdx === 0}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = parseTc(e.target.value);
|
const v = parseTimecode(e.target.value);
|
||||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
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}`}
|
key={`e-${sel.id}-${sel.end}`}
|
||||||
disabled={selIdx === segments.length - 1}
|
disabled={selIdx === segments.length - 1}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = parseTc(e.target.value);
|
const v = parseTimecode(e.target.value);
|
||||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
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 { useTranslation } from "react-i18next";
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
@@ -96,11 +96,13 @@ export function Ctrl({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
align = "center",
|
align = "center",
|
||||||
|
buttonRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
buttonRef?: Ref<HTMLButtonElement>;
|
||||||
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -114,6 +116,7 @@ export function Ctrl({
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex group/ctrl">
|
<div className="relative flex group/ctrl">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
ref={buttonRef}
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="player"
|
size="player"
|
||||||
round
|
round
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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",
|
"droppedSeg": "Dropped segment",
|
||||||
"removeCut": "Remove cut",
|
"removeCut": "Remove cut",
|
||||||
"keptSummary": "{{n}} kept · {{dur}}",
|
"keptSummary": "{{n}} kept · {{dur}}",
|
||||||
"createJoin": "Create joined clip"
|
"createJoin": "Create joined clip",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"cutN": "Cut {{n}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,7 @@
|
|||||||
"cap": {
|
"cap": {
|
||||||
"space": "Space",
|
"space": "Space",
|
||||||
"menu": "Menu"
|
"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",
|
"droppedSeg": "Eldobott szegmens",
|
||||||
"removeCut": "Vágás törlése",
|
"removeCut": "Vágás törlése",
|
||||||
"keptSummary": "{{n}} megtartva · {{dur}}",
|
"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": {
|
"cap": {
|
||||||
"space": "Szóköz",
|
"space": "Szóköz",
|
||||||
"menu": "Menü"
|
"menu": "Menü"
|
||||||
}
|
},
|
||||||
|
"clip": "Klip mód",
|
||||||
|
"clipMark": "A klip eleje / vége itt"
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -142,6 +142,18 @@
|
|||||||
border-color: var(--edge-strong);
|
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
|
/* 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
|
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
|
one to 110 (owner: "shorter lines are easier to follow"). Only where the container can
|
||||||
@@ -280,6 +292,7 @@
|
|||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
[role="button"]:focus-visible,
|
[role="button"]:focus-visible,
|
||||||
[role="tab"]:focus-visible,
|
[role="tab"]:focus-visible,
|
||||||
|
[role="slider"]:focus-visible,
|
||||||
summary:focus-visible {
|
summary:focus-visible {
|
||||||
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
|
/* Solid accent: the earlier 60% mix all but vanished on the dark glass. */
|
||||||
outline: 2px solid var(--accent);
|
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
|
/* …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,
|
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). */
|
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;
|
backdrop-filter: none;
|
||||||
-webkit-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
|
// 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).
|
// 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 {
|
export interface EditSpec {
|
||||||
trim?: { start_s: number; end_s?: number };
|
trim?: { start_s: number; end_s?: number };
|
||||||
segments?: { start_s: number; end_s: number }[];
|
segments?: { start_s: number; end_s: number }[];
|
||||||
@@ -226,6 +235,10 @@ export const downloadsApi = {
|
|||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): Promise<DownloadJob> =>
|
}): Promise<DownloadJob> =>
|
||||||
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
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> =>
|
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||||
req(`/api/downloads/${id}/storyboard`),
|
req(`/api/downloads/${id}/storyboard`),
|
||||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
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);
|
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", () => {
|
it("treats Mod as Ctrl or Cmd", () => {
|
||||||
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
|
expect(matches(press({ key: "k", ctrlKey: true }), "Mod+k")).toBe(true);
|
||||||
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
|
expect(matches(press({ key: "k", metaKey: true }), "Mod+k")).toBe(true);
|
||||||
@@ -111,10 +124,23 @@ describe("dispatch", () => {
|
|||||||
subtitle: noop,
|
subtitle: noop,
|
||||||
info: noop,
|
info: noop,
|
||||||
stats: noop,
|
stats: noop,
|
||||||
|
clip: () => calls.push("clip"),
|
||||||
|
markIn: () => calls.push("markIn"),
|
||||||
|
markOut: () => calls.push("markOut"),
|
||||||
back: () => calls.push("back"),
|
back: () => calls.push("back"),
|
||||||
escape: () => calls.push("escape"),
|
escape: () => calls.push("escape"),
|
||||||
}) as const;
|
}) 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", () => {
|
it("routes each arrow variant to its own id", () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const h = plexHandlers(calls);
|
const h = plexHandlers(calls);
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ export const SHORTCUTS = [
|
|||||||
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
|
{ id: "nextVideo", scope: "ytPlayer", keys: ["Shift+ArrowRight"], labelKey: "keys.video" },
|
||||||
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
|
{ id: "volumeUp", scope: "ytPlayer", keys: ["ArrowUp"], labelKey: "keys.volume" },
|
||||||
{ id: "volumeDown", scope: "ytPlayer", keys: ["ArrowDown"], 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",
|
id: "close",
|
||||||
scope: "ytPlayer",
|
scope: "ytPlayer",
|
||||||
@@ -202,6 +207,9 @@ export const SHORTCUTS = [
|
|||||||
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
{ id: "subtitle", scope: "plexPlayer", keys: ["s"], labelKey: "keys.subtitle" },
|
||||||
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
{ id: "info", scope: "plexPlayer", keys: ["i"], labelKey: "keys.info" },
|
||||||
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
|
{ 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
|
// 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
|
// 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.
|
// 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";
|
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 {
|
function keyMatches(e: KeyboardEvent, name: string): boolean {
|
||||||
if (name === "Space") return e.key === " " || e.code === "Space";
|
if (name === "Space") return e.key === " " || e.code === "Space";
|
||||||
if (name.length === 1) return e.key.toLowerCase() === name.toLowerCase();
|
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 parts = chord.split("+");
|
||||||
const key = parts[parts.length - 1] ?? "";
|
const key = parts[parts.length - 1] ?? "";
|
||||||
const mods = parts.slice(0, -1).map((m) => m.toLowerCase());
|
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.ctrlKey || e.metaKey) !== mods.includes("mod")) return false;
|
||||||
if (e.shiftKey !== mods.includes("shift")) return false;
|
if (e.shiftKey !== mods.includes("shift")) return false;
|
||||||
if (e.altKey !== mods.includes("alt")) 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");
|
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", () => {
|
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'));
|
expect(at, "the escape rule").toBeGreaterThan(css.indexOf('html[data-theme="dark"] .glass'));
|
||||||
const body = css.slice(at, css.indexOf("}", at));
|
const body = css.slice(at, css.indexOf("}", at));
|
||||||
expect(body).toMatch(/(^|[\s;{])backdrop-filter:\s*none/);
|
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