feat(player): clip mode - mark a range of what is playing and cut it into a download

Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
2026-10-10 03:58:27 +02:00
parent f221c1962a
commit 50157cc2fd
28 changed files with 2121 additions and 602 deletions
+15
View File
@@ -545,6 +545,20 @@ of a full-width panel and nothing on the right.
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured 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);
});
});
+336
View File
@@ -0,0 +1,336 @@
import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { log } from "../lib/log";
import { formatTimecode, parseTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
//
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
// around the selection where the handles are. The strip's window follows the selection when it
// settles, not while a handle is being dragged — a window that moved under the pointer would run
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
// precision, and the fields and their tenth-of-a-second steps give the rest.
//
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
// mark the same range by the same rules (lib/clipRange).
export interface ClipSource {
source: ClipRequest["source"];
ref: string;
/** Plex: the audio track that is playing, which the clip carries. */
audioTrack?: number | null;
}
interface Props {
src: ClipSource;
clip: ClipRange;
onChange: (r: ClipRange) => void;
duration: number;
/** The player's position, live. */
current: number;
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
onSeek: (time: number, final: boolean) => void;
/** Play from `time` (the loop's restart, and "Play selection"). */
onPlayFrom: (time: number) => void;
onClose: () => void;
}
const NUDGE_S = 0.1;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
const PLAYHEAD = "bg-warn";
export default function ClipPanel({
src,
clip,
onChange,
duration,
current,
onSeek,
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
const hours = duration >= 3600;
const tc = (s: number) => formatTimecode(s, hours);
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once.
useEffect(() => {
rootRef.current?.querySelector<HTMLElement>('[data-handle="in"]')?.focus();
}, []);
// "Play selection" loops: past Out, back to In.
const restart = useEffectEvent(() => onPlayFrom(clip.start));
useEffect(() => {
if (looping && current >= clip.end) restart();
}, [looping, current, clip.end]);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markIn(clip, time, duration));
onSeek(time, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
onChange(markOut(clip, time, duration));
onSeek(time, final);
};
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
setBusy(false);
}
};
return (
<form
ref={rootRef}
onSubmit={submit}
aria-label={t("clip.title")}
data-testid="clip-panel"
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<span className="flex shrink-0 items-center gap-1.5 font-medium">
<Scissors className="h-4 w-4" aria-hidden="true" />
{t("clip.title")}
</span>
<RangeTrack
label={t("clip.overview")}
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && onSeek(time, true)}
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
regions={[
{
key: "window",
start: view.from,
end: view.to,
className: "border-x border-white/50",
},
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
]}
/>
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
<X />
</IconButton>
</div>
<RangeTrack
label={t("clip.strip")}
duration={duration}
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={onSeek}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
}
regions={[
{
key: "sel",
start: clip.start,
end: clip.end,
className: "bg-accent/25 border-y-2 border-accent",
},
]}
handles={[
{
id: "in",
label: t("clip.in"),
time: clip.start,
...bounds.in,
valueText: tc(clip.start),
step: NUDGE_S,
bigStep: 1,
onChange: moveIn,
barClassName: "bg-white",
},
{
id: "out",
label: t("clip.out"),
time: clip.end,
...bounds.out,
valueText: tc(clip.end),
step: NUDGE_S,
bigStep: 1,
onChange: moveOut,
barClassName: "bg-white",
},
]}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<MarkField
label={t("clip.in")}
value={clip.start}
text={tc(clip.start)}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
onHere={() => moveIn(current, true)}
/>
<MarkField
label={t("clip.out")}
value={clip.end}
text={tc(clip.end)}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
</span>
<Button
variant="overlay"
size="sm"
pressed={looping}
icon={<Repeat />}
onClick={() => {
if (!looping) onPlayFrom(clip.start);
setLooping(!looping);
}}
>
{t("clip.loop")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={255}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
/>
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
</Button>
</div>
</form>
);
}
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
* and "to the playhead". */
function MarkField({
label,
value,
text,
onSet,
nudge,
hereLabel,
onHere,
}: {
label: string;
value: number;
text: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
const commit = (raw: string) => {
const v = parseTimecode(raw);
if (v != null && Math.abs(v - value) > 1e-3) onSet(v);
};
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value - nudge)}
label={t("clip.earlier", { label })}
>
<Minus />
</IconButton>
<Input
// Re-created when the mark moves elsewhere, so the field shows it; a typed draft lives in the
// field until Enter or blur.
key={text}
size="sm"
className="w-24 text-center tabular-nums"
defaultValue={text}
aria-label={label}
onBlur={(e) => commit(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value + nudge)}
label={t("clip.later", { label })}
>
<Plus />
</IconButton>
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
<Crosshair />
</IconButton>
</div>
);
}
@@ -476,7 +476,10 @@ describe("PlayerModal — no side strips", () => {
); );
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "player.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",
);
});
});
+259 -147
View File
@@ -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,32 +812,35 @@ export default function PlayerModal({
}`} }`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div {/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
ref={stageRef} panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
// The wheel over the video is its volume, even inside a stacked card that scrolls. <div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
data-wheel-volume="" <div
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${ ref={stageRef}
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl" // The wheel over the video is its volume, even inside a stacked card that scrolls.
} ${maximized ? "player-stage--max" : ""}`} data-wheel-volume=""
> className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */} through our overlay. */}
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so {/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`: the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`:
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent), YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */} which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
className={
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div <div
ref={mountRef} className={
className={`w-full h-full ${playerError != null ? "invisible" : ""}`} maximized || isFullscreen
/> ? "absolute inset-x-0 top-0 bottom-14"
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel : "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus. cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable, It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
@@ -782,25 +851,25 @@ export default function PlayerModal({
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end. settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */} Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && {playerError == null &&
YT_CLICK_LAYER.map((box, i) => ( YT_CLICK_LAYER.map((box, i) => (
<div <div
key={i} key={i}
data-testid="yt-click-layer" data-testid="yt-click-layer"
onClick={() => { onClick={() => {
focusModal(); focusModal();
togglePlay(); togglePlay();
}} }}
title={t("player.shortcutsHint")} title={t("player.shortcutsHint")}
aria-hidden="true" aria-hidden="true"
style={box} style={box}
className={`absolute z-base cursor-pointer ${ className={`absolute z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : "" nativeControls ? "pointer-events-none" : ""
}`} }`}
/> />
))} ))}
</div> </div>
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's {/* Our own control bar for the big views (maximise / our fullscreen), where the card's
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
<body> portal would not) and DOCKED in its own strip: the video area above is inset by <body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
@@ -809,124 +878,154 @@ export default function PlayerModal({
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback; seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen: the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen:
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */} add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
{isFullscreen && <Toaster overlay order={overlayOrder} />} {isFullscreen && <Toaster overlay order={overlayOrder} />}
{(maximized || isFullscreen) && ( {(maximized || isFullscreen) && (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white" className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
> >
{hasQueue && ( {hasQueue && (
<> <>
<PlayerTransportControls <PlayerTransportControls
variant="docked" variant="docked"
index={index} index={index}
queueLength={queue!.length} queueLength={queue!.length}
autoMode={autoMode} autoMode={autoMode}
loopMode={loopMode} loopMode={loopMode}
onPrev={goPrev} onPrev={goPrev}
onNext={goNext} onNext={goNext}
onCycleAuto={cycleAuto} onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop} onCycleLoop={cycleLoop}
/> />
<span className="h-4 w-px bg-white/20" /> <span className="h-4 w-px bg-white/20" />
</> </>
)} )}
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */} painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />} {!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && ( {!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" /> <DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)} )}
{!navigated && ( {/* The clip panel lives under the video in the card: this steps out of the big view. */}
{playerError == null && (
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
<Scissors />
</IconButton>
)}
{!navigated && (
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button <Button
variant="overlay" variant="overlay"
size="sm" size="sm"
pressed={watched} onClick={(e) => {
onClick={() => setWatched(!watched)} e.stopPropagation();
label={watched ? t("player.watchedUnmark") : t("player.markWatched")} exitBigView();
icon={watched ? <CheckCheck /> : <Check />} setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
> >
<span className="hidden md:inline"> <span className="hidden sm:inline">{t("player.exitMaximize")}</span>
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</Button> </Button>
)}
<span className="h-4 w-px bg-white/20" />
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
the window-filling view is the biggest, and YouTube's own button opens the
system player. */}
{canFullscreenElements() && (
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
)}
<Button
variant="overlay"
size="sm"
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</Button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div> </div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span> )}
</div> {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
)} {playerError == null && nativeControls && (
{playerError != null && ( <div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center"> <Settings className="h-3.5 w-3.5" />
<div className="max-w-sm"> {t("player.nativeControls")}
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
</div> </div>
</div> )}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-all"
style={{ width: `${volumeUi}%` }}
/>
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === YT_API_LOAD_FAILED
? t("player.apiLoadFailedBody")
: playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
variant="primary"
icon={<ExternalLink />}
>
{t("player.openOnYoutube")}
</LinkButton>
</div>
</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> </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,
});
});
});
+366 -284
View File
@@ -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,309 +1393,343 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
: "opacity-0 pointer-events-none" : "opacity-0 pointer-events-none"
}`} }`}
> >
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on {/* Clip mode: the clip panel takes the seek bar's and the control row's place (it has a
timeline of its own). Its seeks wait for the release: a seek may restart the transcode. */}
{clip ? (
<div className="overflow-hidden rounded-xl">
<ClipPanel
src={{ source: "plex", ref: id, audioTrack: audioOrd ?? 0 }}
clip={clip}
onChange={setClip}
duration={duration}
current={abs}
onSeek={(time, final) => {
if (final) seekTo(time);
}}
onPlayFrom={(time) => seekTo(time, true)}
onClose={closeClip}
/>
</div>
) : (
<>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. The track stays thin 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`
makes a drag scrub instead of panning the page. */} makes a drag scrub instead of panning the page. */}
<div
className="group relative flex h-11 cursor-pointer touch-none items-center"
onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}}
onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
const tt = frac * duration;
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(null);
}}
onMouseLeave={() => setHover(null)}
>
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<div <div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white" className="group relative flex h-11 cursor-pointer touch-none items-center"
style={{ left: `${hover.frac * 100}%` }} onPointerDown={(e) => {
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}}
onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const frac = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1));
const tt = frac * duration;
setHover({ frac, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={(e) => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
// A finger never "leaves": its time tip goes with it.
if (e.pointerType === "touch") setHover(null);
}}
onPointerCancel={() => {
setScrub(null);
setHover(null);
}}
onMouseLeave={() => setHover(null)}
> >
{fmt(hover.t)} {/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${hover.frac * 100}%` }}
>
{fmt(hover.t)}
</div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div> </div>
)}
<div className="relative h-1.5 w-full rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 bg-white/30 rounded-full"
style={{ width: `${bufPct}%` }}
/>
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
}}
/>
))}
<div
className="absolute inset-y-0 left-0 bg-accent rounded-full"
style={{ width: `${headPct}%` }}
/>
{/* The head: on hover for a mouse, always on touch (C3), where nothing hovers. */}
<div
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
scrub != null
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/>
</div>
</div>
{/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next {/* The C5 row, one line on a portrait tablet: play/pause, Back, Next (only with a next
item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop item), mute, the time, then audio & subtitles, the fixed ⋯ and fullscreen. No Stop
(closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the (closing is stopping), no Restart (Back is, and the card's), no ±10 s (← / → and the
touch double-tap are). */} touch double-tap are). */}
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start"> <Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />} {playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl> </Ctrl>
<Ctrl <Ctrl
label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")} label={backToPrev ? t("plex.player.prev") : t("plex.player.restart")}
onClick={backOrPrev} onClick={backOrPrev}
> >
<SkipBack className="w-5 h-5" /> <SkipBack className="w-5 h-5" />
</Ctrl> </Ctrl>
{showNext && ( {showNext && (
<Ctrl <Ctrl
label={t("plex.player.next")} label={t("plex.player.next")}
onClick={() => nextId && go(nextId)} onClick={() => nextId && go(nextId)}
disabled={!nextId} disabled={!nextId}
> >
<SkipForward className="w-5 h-5" /> <SkipForward className="w-5 h-5" />
</Ctrl> </Ctrl>
)}
<div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)} )}
</Ctrl>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus, <div className="flex items-center gap-2">
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
none of them. The level shows above it while hovered. A pointer drag hands the focus none of them. The level shows above it while hovered. A pointer drag hands the focus
back so Space and the arrows keep driving the player, not the slider. */} back so Space and the arrows keep driving the player, not the slider. */}
{/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the {/* Not on touch: a tablet's volume is its buttons (iPadOS ignores a page's), and the
row has to fit a portrait screen. */} row has to fit a portrait screen. */}
<div <div
className="relative w-24 [@media(hover:none)]:hidden" className="relative w-24 [@media(hover:none)]:hidden"
onPointerEnter={() => setVolHover(true)} onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)} onPointerLeave={() => setVolHover(false)}
> >
<Slider <Slider
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
value={muted ? 0 : volume} value={muted ? 0 : volume}
aria-label={t("plex.player.volume")} aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)} onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()} onPointerUp={(e) => e.currentTarget.blur()}
/> />
{volHover && ( {volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"> <div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)} {Math.round((muted ? 0 : volume) * 100)}
</div>
)}
</div> </div>
)} </div>
</div>
</div>
<div className="min-w-0 truncate text-xs tabular-nums text-white/90"> <div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)} {fmt(abs)} / {fmt(duration)}
{/* Projected finish time (wall clock now + time remaining), in the clock's own format — {/* Projected finish time (wall clock now + time remaining), in the clock's own format —
pushes later while paused, since the remaining time then rides the advancing clock. */} pushes later while paused, since the remaining time then rides the advancing clock. */}
{duration > 0 && ( {duration > 0 && (
<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> }
)} )
</div> </span>
)}
</div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
a track keeps it open: the change is seen where it was made, and the other group is a track keeps it open: the change is seen where it was made, and the other group is
one arrow away. */} one arrow away. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && ( {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<> <>
<IconButton <IconButton
variant="overlay" variant="overlay"
size="player" size="player"
round round
ref={setTracksReference} ref={setTracksReference}
{...tracksPop.getReferenceProps()} {...tracksPop.getReferenceProps()}
aria-expanded={panel === "tracks"} aria-expanded={panel === "tracks"}
label={t("plex.player.tracks")} label={t("plex.player.tracks")}
>
<Languages />
</IconButton>
<PopoverSurface
popover={tracksPop}
ariaLabel={t("plex.player.tracks")}
className={`${PLAYER_MENU} w-56`}
>
{/* Portaled, but a React child of the player: a click or a wheel here must not
reach the stage's play-toggle and volume handlers. */}
<div
ref={tracksFadeRef}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }}
className="overflow-auto no-scrollbar p-2"
>
{detail.audio_streams.length > 1 && (
<div role="group" aria-label={t("plex.player.audio")}>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<PopoverSurface
popover={morePop}
ariaLabel={t("plex.player.more")}
className={`${PLAYER_MENU} w-60 p-1.5`}
>
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
<PopoverItem
key={it.id}
tone="media"
label={it.label}
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
// opened from here returns the focus to where it found it.
onSelect={() => closeThenAct(() => closePanel("more"), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
> >
{it.icon} <Languages />
{it.label} </IconButton>
</PopoverItem> <PopoverSurface
))} popover={tracksPop}
ariaLabel={t("plex.player.tracks")}
className={`${PLAYER_MENU} w-56`}
>
{/* Portaled, but a React child of the player: a click or a wheel here must not
reach the stage's play-toggle and volume handlers. */}
<div
ref={tracksFadeRef}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }}
className="overflow-auto no-scrollbar p-2"
>
{detail.audio_streams.length > 1 && (
<div role="group" aria-label={t("plex.player.audio")}>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<PopoverItem
key={a.ord}
tone="media"
role="menuitemradio"
checked={(audioOrd ?? 0) === a.ord}
label={audioName(a)}
onSelect={() => changeAudio(a.ord)}
className={`${TRACK_ROW} ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={(audioOrd ?? 0) === a.ord} />
<span className="truncate">{audioName(a)}</span>
</PopoverItem>
))}
</div>
)}
{textSubs.length > 0 && (
<div role="group" aria-label={t("plex.player.subtitles")}>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<PopoverItem
tone="media"
role="menuitemradio"
checked={subOrd == null}
label={t("plex.player.subOff")}
onSelect={() => changeSubtitle(null)}
className={`${TRACK_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
>
<MenuCheck kind="one" on={subOrd == null} />
<span className="truncate">{t("plex.player.subOff")}</span>
</PopoverItem>
{textSubs.map((sub) => (
<PopoverItem
key={sub.ord}
tone="media"
role="menuitemradio"
checked={subOrd === sub.ord}
label={subtitleName(sub)}
onSelect={() => changeSubtitle(sub.ord)}
className={`${TRACK_ROW} ${
subOrd === sub.ord ? "text-accent" : "text-white"
}`}
>
<MenuCheck kind="one" on={subOrd === sub.ord} />
<span className="truncate">{subtitleName(sub)}</span>
</PopoverItem>
))}
</div>
)}
</div>
</PopoverSurface>
</>
)}
{/* Clip mode (`c`): mark a range of what is playing and cut it into a download. */}
{duration > 0 && (
<Ctrl
label={t("clip.open")}
onClick={() => setClip(openAt(abs, duration))}
buttonRef={clipButtonRef}
>
<Scissors className="w-5 h-5" />
</Ctrl>
)}
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
so the ⋯ also closes it. */}
<div className="relative">
<IconButton
variant="overlay"
size="player"
round
ref={moreTriggerRef}
{...morePop.getReferenceProps()}
aria-expanded={panel === "more" || panel === "settings"}
label={t("plex.player.more")}
>
<MoreHorizontal />
</IconButton>
<PopoverSurface
popover={morePop}
ariaLabel={t("plex.player.more")}
className={`${PLAYER_MENU} w-60 p-1.5`}
>
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
{moreItems.map((it) => (
<PopoverItem
key={it.id}
tone="media"
label={it.label}
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
// opened from here returns the focus to where it found it.
onSelect={() => closeThenAct(() => closePanel("more"), it.run)}
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
>
{it.icon}
{it.label}
</PopoverItem>
))}
</div>
</PopoverSurface>
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div> </div>
</PopoverSurface> {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
{panel === "settings" && (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={menuTriggerRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button
did nothing there, and the windowed player already fills the window. */} did nothing there, and the windowed player already fills the window. */}
{canFullscreenElements() && ( {canFullscreenElements() && (
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end"> <Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />} {fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Ctrl> </Ctrl>
)} )}
</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");
});
});
+92 -150
View File
@@ -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,104 +422,92 @@ 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);
onPointerMove={(e) => seek(time);
setHover({ }}
x: e.clientX - e.currentTarget.getBoundingClientRect().left, onHover={setHover}
time: timeAt(e.clientX), className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
}) background={
} stripCells > 0 && (
onPointerLeave={() => setHover(null)} <div className="absolute inset-0 flex pointer-events-none">
> {Array.from({ length: stripCells }).map((_, i) => (
{/* aspect-correct filmstrip */} <div
{stripCells > 0 && ( key={i}
<div className="absolute inset-0 flex pointer-events-none"> className="h-full flex-1"
{Array.from({ length: stripCells }).map((_, i) => ( style={tileStyle(
<div Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
key={i} )}
className="h-full flex-1" />
style={tileStyle( ))}
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)), </div>
)} )
/> }
))} regions={segments.map((s) => ({
</div> key: s.id,
)} start: s.start,
{/* segment tint: dropped = dimmed + hatched */} end: s.end,
{segments.map((s) => ( // segment tint: dropped = dimmed + hatched
<div className: clsx(
key={s.id} s.end < duration && "border-r border-black/40",
className={clsx(
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
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:
? {} "repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
: { },
backgroundImage: title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)", onPointerDown: () => setSelId(s.id),
}), children: (
}} // keep/drop toggle
onPointerDown={() => setSelId(s.id)} <Pressable
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} aria-pressed={s.keep}
> onPointerDown={(e) => {
{/* keep/drop toggle */} e.stopPropagation();
<Pressable toggleKeep(s.id);
aria-pressed={s.keep} }}
onPointerDown={(e) => { className={clsx(
e.stopPropagation(); "absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
toggleKeep(s.id); s.keep ? "text-accent" : "text-muted",
}} )}
className={clsx( title={s.keep ? t("editor.drop") : t("editor.keep")}
"absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs", >
s.keep ? "text-accent" : "text-muted", {s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
)} </Pressable>
title={s.keep ? t("editor.drop") : t("editor.keep")} ),
> }))}
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} // The cuts between segments: dragged, or stepped with the arrows once focused.
</Pressable> handles={boundaries.map((b, bi) => ({
</div> id: `cut-${bi}`,
))} label: t("editor.cutN", { n: bi + 1 }),
{/* draggable boundary markers */} time: b,
{boundaries.map((b, bi) => ( min: segments[bi]!.start + MIN_SEG,
<div max: segments[bi + 1]!.end - MIN_SEG,
key={bi} valueText: tc(b),
className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base" step: 0.1,
style={{ left: pct(b) }} bigStep: 1,
> onChange: (time: number) => moveBoundary(bi, time),
<div adornment: (
className="w-1 h-full bg-white/90 cursor-ew-resize rounded-sm" <Pressable
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
setDrag({ type: "boundary", bi }); deleteBoundary(bi);
}} }}
/> className="absolute top-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
<Pressable title={t("editor.removeCut")}
onPointerDown={(e) => { >
e.stopPropagation(); <X className="w-3 h-3" />
deleteBoundary(bi); </Pressable>
}} ),
className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger" }))}
title={t("editor.removeCut")}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
{/* playhead */}
<div
className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu"
style={{ left: pct(current) }}
/> />
</div> </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
+7 -7
View File
@@ -35,13 +35,13 @@ 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` |
## The size contract (E10 S1c) ## The size contract (E10 S1c)
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
//
// The shared time track: a handle is a slider the keyboard steps (and the step stops at its range),
// a drag reports its moves as not-final and its release as final, a press on the bare track seeks,
// and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack";
afterEach(cleanup);
beforeAll(() => {
// jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads.
class PointerEventStub extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
});
function box(el: Element, left = 0, width = 1000) {
el.getBoundingClientRect = () =>
({
left,
width,
top: 0,
height: 40,
right: left + width,
bottom: 40,
x: left,
y: 0,
}) as DOMRect;
}
function handle(over: Partial<TrackHandle> = {}): TrackHandle {
return {
id: "in",
time: 50,
min: 10,
max: 90,
label: "In",
valueText: "0:50.0",
step: 0.1,
bigStep: 1,
onChange: vi.fn(),
...over,
};
}
describe("RangeTrack", () => {
it("draws a handle as a slider with its time as the value", () => {
render(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
const s = screen.getByRole("slider", { name: "In" });
expect(s.getAttribute("aria-valuenow")).toBe("50");
expect(s.getAttribute("aria-valuetext")).toBe("0:50.0");
expect(s.style.left).toBe("50%");
});
it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
const right = new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
});
s.dispatchEvent(right);
expect(h.onChange).toHaveBeenLastCalledWith(50.1, true);
expect(right.defaultPrevented).toBe(true);
fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(49, true);
});
it("stops a step at the handle's range", () => {
const h = handle({ time: 89.95 });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
fireEvent.keyDown(s, { key: "Home" });
expect(h.onChange).toHaveBeenLastCalledWith(10, true);
});
it("leaves a modified arrow to the page", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), {
key: "ArrowRight",
ctrlKey: true,
});
expect(h.onChange).not.toHaveBeenCalled();
});
it("reports a drag's moves as not final and its release as final, clamped to the range", () => {
const h = handle();
const onSeek = vi.fn();
render(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, false);
fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
// The handle's press is not a seek of the track under it.
expect(onSeek).not.toHaveBeenCalled();
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(50, false);
fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, false);
fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, true);
});
it("maps time through its window, and draws nothing that falls outside it", () => {
const onSeek = vi.fn();
render(
<RangeTrack
duration={3600}
view={{ from: 600, to: 700 }}
label="Zoom"
playhead={50}
onSeek={onSeek}
handles={[
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
]}
/>,
);
const track = screen.getByRole("group", { name: "Zoom" });
box(track);
expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%");
expect(screen.queryByRole("slider", { name: "Out" })).toBeNull();
expect(screen.queryByTestId("range-playhead")).toBeNull();
fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(610, false);
});
});
describe("RulerTicks", () => {
it("picks a step that keeps the window to a handful of ticks", () => {
const { container } = render(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
});
});
+270
View File
@@ -0,0 +1,270 @@
import {
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
//
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
// editors that drew the hit area as thin as the bar were the ones people missed.
//
// Every move reports `final`: false while a pointer drags, true on release or a key. A player whose
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
export interface RangeWindow {
from: number;
to: number;
}
export interface TrackHandle {
id: string;
time: number;
/** The range the handle may move in (seconds); a move past it stops at the edge. */
min: number;
max: number;
/** Accessible name ("In", "Cut 2"). */
label: string;
/** The time as read out (aria-valuetext), in the caller's own format. */
valueText: string;
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
step: number;
bigStep: number;
onChange: (time: number, final: boolean) => void;
/** The drawn bar's look (its colour); the hit area around it is the track's. */
barClassName?: string;
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
adornment?: ReactNode;
}
interface TrackRegion {
key: string | number;
start: number;
end: number;
className?: string;
style?: CSSProperties;
title?: string;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
children?: ReactNode;
}
interface Props {
/** The media's length; with no `view` the track shows all of it. */
duration: number;
view?: RangeWindow;
playhead?: number | null;
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
onSeek?: (time: number, final: boolean) => void;
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
onHover?: (h: { x: number; time: number } | null) => void;
handles?: readonly TrackHandle[];
regions?: readonly TrackRegion[];
background?: ReactNode;
/** The track's box: its height, surface and edge. */
className?: string;
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** The time under `clientX` on the track that holds `el`. */
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
const track = el.closest("[data-range-track]") ?? el;
const r = track.getBoundingClientRect();
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
return win.from + frac * (win.to - win.from);
}
export function RangeTrack({
duration,
view,
playhead,
onSeek,
onHover,
handles = [],
regions = [],
background,
className,
label,
playheadClassName = "bg-white",
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const span = Math.max(win.to - win.from, 1e-6);
const frac = (t: number) => (t - win.from) / span;
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<string | null>(null);
const [scrubbing, setScrubbing] = useState(false);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
}
};
return (
<div
data-range-track=""
role="group"
aria-label={label}
className={clsx("relative touch-none select-none overflow-hidden", className)}
onPointerDown={(e) => {
if (!onSeek || e.button !== 0) return;
capture(e);
setScrubbing(true);
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerMove={(e) => {
const time = timeAtPointer(e.currentTarget, e.clientX, win);
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
if (scrubbing) onSeek?.(time, false);
}}
onPointerUp={(e) => {
if (!scrubbing) return;
setScrubbing(false);
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setScrubbing(false)}
onPointerLeave={() => onHover?.(null)}
>
{background}
{regions.map((r) => {
const a = clamp(frac(r.start), 0, 1);
const b = clamp(frac(r.end), 0, 1);
if (b <= a && r.end > r.start) return null; // wholly outside the window
return (
<div
key={r.key}
className={clsx("absolute inset-y-0", r.className)}
style={{ left: pct(a), width: pct(b - a), ...r.style }}
title={r.title}
onPointerDown={r.onPointerDown}
>
{r.children}
</div>
);
})}
{playhead != null && playhead >= win.from && playhead <= win.to && (
<div
data-testid="range-playhead"
className={clsx(
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
playheadClassName,
)}
style={{ left: pct(frac(playhead)) }}
/>
)}
{handles.map((h) => {
const f = frac(h.time);
if (f < 0 || f > 1) return null;
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? h.bigStep : h.step;
const to: Record<string, number> = {
ArrowLeft: h.time - big,
ArrowDown: h.time - big,
ArrowRight: h.time + big,
ArrowUp: h.time + big,
PageDown: h.time - h.bigStep,
PageUp: h.time + h.bigStep,
Home: h.min,
End: h.max,
};
const next = to[e.key];
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
// skips a handled key).
e.preventDefault();
e.stopPropagation();
move(next, true);
};
return (
<div
key={h.id}
role="slider"
tabIndex={0}
aria-label={h.label}
aria-valuemin={Math.round(h.min * 10) / 10}
aria-valuemax={Math.round(h.max * 10) / 10}
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow.
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
capture(e);
setDragging(h.id);
}}
onPointerMove={(e) => {
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerUp={(e) => {
if (dragging !== h.id) return;
e.stopPropagation();
setDragging(null);
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setDragging(null)}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
{h.adornment}
</div>
);
})}
</div>
);
}
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
* each labelled by `format`. */
export function RulerTicks({
view,
format,
max = 8,
className,
}: {
view: RangeWindow;
format: (t: number) => string;
max?: number;
className?: string;
}) {
const span = Math.max(view.to - view.from, 1e-6);
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
const ticks: number[] = [];
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
return (
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
{ticks.map((t) => (
<div
key={t}
className="absolute bottom-0 h-2 border-l border-current"
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
>
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
{format(t)}
</span>
</div>
))}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"title": "Clip",
"open": "Make a clip",
"close": "Close the clip panel",
"overview": "Whole video",
"strip": "Clip range",
"in": "Start",
"out": "End",
"inHere": "Start at the current position",
"outHere": "End at the current position",
"earlier": "{{label}}: a tenth of a second earlier",
"later": "{{label}}: a tenth of a second later",
"length": "Length {{length}}",
"loop": "Play selection",
"name": "Clip name",
"namePlaceholder": "Clip name (optional)",
"cut": "Cut clip",
"queued": "The clip is being made — it will be in your downloads",
"view": "Downloads",
"failed": "Couldn’t make the clip"
}
+3 -1
View File
@@ -39,5 +39,7 @@
"droppedSeg": "Dropped segment", "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}}"
} }
+3 -1
View File
@@ -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"
} }
+21
View File
@@ -0,0 +1,21 @@
{
"title": "Klip",
"open": "Klip készítése",
"close": "Klippanel bezárása",
"overview": "A teljes videó",
"strip": "A klip tartománya",
"in": "Eleje",
"out": "Vége",
"inHere": "Eleje a mostani pozíciónál",
"outHere": "Vége a mostani pozíciónál",
"earlier": "{{label}}: egy tized másodperccel korábban",
"later": "{{label}}: egy tized másodperccel később",
"length": "Hossz {{length}}",
"loop": "Kijelölés lejátszása",
"name": "A klip neve",
"namePlaceholder": "A klip neve (nem kötelező)",
"cut": "Klip vágása",
"queued": "Készül a klip — a letöltéseid közé kerül",
"view": "Letöltések",
"failed": "Nem sikerült elkészíteni a klipet"
}
+3 -1
View File
@@ -39,5 +39,7 @@
"droppedSeg": "Eldobott szegmens", "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"
} }
+3 -1
View File
@@ -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
View File
@@ -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;
} }
+13
View File
@@ -51,6 +51,15 @@ export type DownloadJobKind = "download" | "edit" | "upload" | "clip";
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into // 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"),
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_CLIP_S,
MAX_CLIP_S,
handleBounds,
markIn,
markOut,
openAt,
zoomWindow,
} from "./clipRange";
const D = 3600;
describe("clipRange", () => {
it("opens a default-length range at the playhead, shorter at the item's end", () => {
expect(openAt(100, D)).toEqual({ start: 100, end: 100 + DEFAULT_CLIP_S });
expect(openAt(3590, D)).toEqual({ start: 3590, end: 3600 });
expect(openAt(3600, D)).toEqual({ start: 3599, end: 3600 });
});
it("moves In alone while the range stays one the server takes", () => {
expect(markIn({ start: 100, end: 130 }, 90, D)).toEqual({ start: 90, end: 130 });
});
it("carries Out along when In passes it, and keeps the cap when In goes too far back", () => {
expect(markIn({ start: 100, end: 130 }, 200, D)).toEqual({ start: 200, end: 230 });
expect(markIn({ start: 1000, end: 1030 }, 100, D)).toEqual({
start: 100,
end: 100 + MAX_CLIP_S,
});
});
it("carries In along when Out goes before it or too far ahead", () => {
expect(markOut({ start: 100, end: 130 }, 150, D)).toEqual({ start: 100, end: 150 });
expect(markOut({ start: 100, end: 130 }, 50, D)).toEqual({ start: 20, end: 50 });
expect(markOut({ start: 100, end: 130 }, 1000, D)).toEqual({ start: 400, end: 1000 });
});
it("every marked range is at least a second and at most the cap", () => {
for (const [r, t] of [
[{ start: 0, end: 30 }, 0],
[{ start: 0, end: 30 }, 3600],
[{ start: 3000, end: 3600 }, 0],
] as const) {
for (const next of [markIn(r, t, D), markOut(r, t, D)]) {
expect(next.end - next.start).toBeGreaterThanOrEqual(1);
expect(next.end - next.start).toBeLessThanOrEqual(MAX_CLIP_S);
expect(next.start).toBeGreaterThanOrEqual(0);
expect(next.end).toBeLessThanOrEqual(D);
}
}
});
it("bounds each handle so a drag never breaks the range", () => {
expect(handleBounds({ start: 100, end: 130 }, D)).toEqual({
in: { min: 0, max: 129 },
out: { min: 101, max: 700 },
});
});
it("zooms to the range with room either side, inside the item", () => {
expect(zoomWindow({ start: 100, end: 140 }, D)).toEqual({ from: 90, to: 150 });
expect(zoomWindow({ start: 2, end: 10 }, D)).toEqual({ from: 0, to: 15 });
});
});
+55
View File
@@ -0,0 +1,55 @@
// The range an ad-hoc clip is cut from, marked in a player: the rules the clip panel's handles, its
// In / Out fields and the players' `[` / `]` keys all go by, so every way of marking keeps a range the
// server takes (backend downloads/clip.py: at least MIN_CLIP_S, at most MAX_CLIP_S — the owner's cap).
const MIN_CLIP_S = 1;
export const MAX_CLIP_S = 600;
/** What a fresh mark spans before the second end is set: a moment, not the cap. */
export const DEFAULT_CLIP_S = 30;
export interface ClipRange {
start: number;
end: number;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** A range opened at `t`: from there, DEFAULT_CLIP_S long (shorter at the item's end). */
export function openAt(t: number, duration: number): ClipRange {
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
return { start, end: Math.min(duration, start + DEFAULT_CLIP_S) };
}
/** In moved to `t`. Out stays where it is while the range stays one the server takes; otherwise it
* follows (a fresh DEFAULT_CLIP_S after an In past it, the cap after an In too far before it). */
export function markIn(r: ClipRange, t: number, duration: number): ClipRange {
const start = clamp(t, 0, Math.max(0, duration - MIN_CLIP_S));
let end = r.end;
if (end - start < MIN_CLIP_S) end = Math.min(duration, start + DEFAULT_CLIP_S);
else if (end - start > MAX_CLIP_S) end = start + MAX_CLIP_S;
return { start, end };
}
/** Out moved to `t`; In follows the same way when the range would stop being one. */
export function markOut(r: ClipRange, t: number, duration: number): ClipRange {
const end = clamp(t, Math.min(MIN_CLIP_S, duration), duration);
let start = r.start;
if (end - start < MIN_CLIP_S) start = Math.max(0, end - DEFAULT_CLIP_S);
else if (end - start > MAX_CLIP_S) start = end - MAX_CLIP_S;
return { start, end };
}
/** Where each end may be dragged to without the other moving. */
export function handleBounds(r: ClipRange, duration: number) {
return {
in: { min: Math.max(0, r.end - MAX_CLIP_S), max: r.end - MIN_CLIP_S },
out: { min: r.start + MIN_CLIP_S, max: Math.min(duration, r.start + MAX_CLIP_S) },
};
}
/** The zoomed strip's window: the range with a margin either side (a quarter of it, at least 5 s),
* so both ends sit inside with room to move them out. */
export function zoomWindow(r: ClipRange, duration: number): { from: number; to: number } {
const pad = Math.max(5, (r.end - r.start) * 0.25);
return { from: Math.max(0, r.start - pad), to: Math.min(duration, r.end + pad) };
}
+26
View File
@@ -41,6 +41,19 @@ describe("matches", () => {
expect(matches(press({ key: "k", ctrlKey: true, altKey: true }), "Mod+k")).toBe(false); 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);
+17
View File
@@ -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/);
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatTimecode, parseTimecode } from "./timecode";
describe("formatTimecode", () => {
it("is m:ss.d below an hour and h:mm:ss.d from one", () => {
expect(formatTimecode(12.44)).toBe("0:12.4");
expect(formatTimecode(75)).toBe("1:15.0");
expect(formatTimecode(3723.4)).toBe("1:02:03.4");
});
it("shows hours when the item reaches one, so an item's fields agree", () => {
expect(formatTimecode(65, true)).toBe("0:01:05.0");
});
it("carries a rounded tenth into the next second, never a 60.0", () => {
expect(formatTimecode(59.96)).toBe("1:00.0");
});
it("reads a bad clock as zero", () => {
expect(formatTimecode(NaN)).toBe("0:00.0");
expect(formatTimecode(-3)).toBe("0:00.0");
});
});
describe("parseTimecode", () => {
it("reads every shape it writes, and bare seconds", () => {
expect(parseTimecode("0:12.4")).toBeCloseTo(12.4);
expect(parseTimecode("1:02:03.4")).toBeCloseTo(3723.4);
expect(parseTimecode(" 90.5 ")).toBeCloseTo(90.5);
});
it("is null for what is not a timecode", () => {
for (const v of ["", "abc", "1::2", "1:2:3:4", "-5", "1:-2"])
expect(parseTimecode(v)).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
// An editing timecode: to the tenth of a second, the precision a cut is marked at (the editor's
// segment fields, the clip panel's In / Out). Hours appear only when the item reaches an hour, so a
// short's "0:12.4" stays short and a film reads "1:02:03.4". `formatDuration` is the whole-second
// clock for a card or a player bar; this one is for a field someone types a cut into.
/** `s` as `m:ss.d`, or `h:mm:ss.d` when `hours` (pass `duration >= 3600` so one item's fields agree). */
export function formatTimecode(s: number, hours = false): string {
if (!isFinite(s) || s < 0) s = 0;
const tenths = Math.round(s * 10);
const h = Math.floor(tenths / 36000);
const m = Math.floor((tenths % 36000) / 600);
const sec = ((tenths % 600) / 10).toFixed(1).padStart(4, "0");
return hours || h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
/** A typed timecode back to seconds: `h:mm:ss.d`, `m:ss.d` or bare seconds. Null when it is not one. */
export function parseTimecode(v: string): number | null {
const parts = v.trim().split(":");
if (parts.length > 3 || parts.some((p) => p.trim() === "")) return null;
let total = 0;
for (const p of parts) {
const n = Number(p);
if (!isFinite(n) || n < 0) return null;
total = total * 60 + n;
}
return total;
}