fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item neither closes the player nor steps the queue, and Plex's auto-skip waits. - Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the clip; Back under a dialog is the dialog's; no range opens while the length is unknown. - YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps its name, Loop and focus when the view is maximised or restored under it. - A typed time stays in its field until taken, then shows where it landed; unreadable stays marked invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too). - RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's. - The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the cut's hit area.
This commit is contained in:
+11
-4
@@ -556,16 +556,23 @@ of a full-width panel and nothing on the right.
|
|||||||
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
|
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
|
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
|
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
|
ends are white, a focused end's ring and the playhead amber (a light theme's accent all but vanished
|
||||||
|
there). A typed time that cannot be read stays in its field, marked invalid; one past the end shows
|
||||||
|
where it landed (decimal point or comma). The strip's window
|
||||||
follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
|
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
|
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
|
||||||
player; the scissors gets the focus back. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a
|
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
|
||||||
|
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
|
||||||
|
the picture (Plex: the info, the up-next card, the countdown); ⌫ under a dialog is the dialog's. The
|
||||||
|
scissors the user can see gets the focus back (maximised: the docked bar's), else the player. The panel
|
||||||
|
keeps its name, Loop and focus when the view is maximised or restored under it. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a
|
||||||
frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges
|
frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges
|
||||||
and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift
|
and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift
|
||||||
further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and
|
further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and
|
||||||
Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A
|
Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A
|
||||||
press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual
|
press outside the frame is still the video's. 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`).
|
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to
|
||||||
|
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
|
||||||
|
|
||||||
## Dialogs — _Decided_ (C4, built in S3f)
|
## Dialogs — _Decided_ (C4, built in S3f)
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function Harness({
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onPlayFrom = () => {},
|
onPlayFrom = () => {},
|
||||||
onSeek = () => {},
|
onSeek = () => {},
|
||||||
|
duration = 3600,
|
||||||
}: {
|
}: {
|
||||||
src?: ClipSource;
|
src?: ClipSource;
|
||||||
start?: ClipRange;
|
start?: ClipRange;
|
||||||
@@ -56,6 +57,7 @@ function Harness({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onPlayFrom?: (t: number) => void;
|
onPlayFrom?: (t: number) => void;
|
||||||
onSeek?: (t: number, final: boolean) => void;
|
onSeek?: (t: number, final: boolean) => void;
|
||||||
|
duration?: number;
|
||||||
}) {
|
}) {
|
||||||
const [clip, setClip] = useState(start);
|
const [clip, setClip] = useState(start);
|
||||||
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
const [crop, setCrop] = useState<ClipCrop | null>(null);
|
||||||
@@ -67,7 +69,7 @@ function Harness({
|
|||||||
src={src}
|
src={src}
|
||||||
clip={clip}
|
clip={clip}
|
||||||
onChange={setClip}
|
onChange={setClip}
|
||||||
duration={3600}
|
duration={duration}
|
||||||
current={current}
|
current={current}
|
||||||
picture={16 / 9}
|
picture={16 / 9}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
@@ -99,6 +101,53 @@ describe("ClipPanel", () => {
|
|||||||
expect(range()).toBe("140-170");
|
expect(range()).toBe("140-170");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the focus in a time field that took a typed time on Enter", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||||||
|
field().focus();
|
||||||
|
fireEvent.change(field(), { target: { value: "2:20" } });
|
||||||
|
fireEvent.keyDown(field(), { key: "Enter" });
|
||||||
|
expect(range()).toBe("140-170");
|
||||||
|
expect(document.activeElement).toBe(field());
|
||||||
|
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the mark it holds, not a time it could not take", () => {
|
||||||
|
render(<Harness start={{ start: 3500, end: 3600 }} />);
|
||||||
|
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
|
||||||
|
// Past the end: clamped back to the end, and the field says so.
|
||||||
|
fireEvent.change(out(), { target: { value: "1:15:00" } });
|
||||||
|
fireEvent.blur(out());
|
||||||
|
expect(range()).toBe("3500-3600");
|
||||||
|
expect(out().value).toBe("1:00:00.0");
|
||||||
|
expect(out().getAttribute("aria-invalid")).toBeNull();
|
||||||
|
// Not a time at all: kept for fixing, and marked as not taken.
|
||||||
|
fireEvent.change(out(), { target: { value: "abc" } });
|
||||||
|
fireEvent.blur(out());
|
||||||
|
expect(out().value).toBe("abc");
|
||||||
|
expect(out().getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(range()).toBe("3500-3600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a decimal comma, as a Hungarian hand types it", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
|
||||||
|
fireEvent.change(field, { target: { value: "1:42,5" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
expect(range()).toBe("102.5-130");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never seeks the player onto the item's very end (that would end it, and the clip with it)", () => {
|
||||||
|
const onSeek = vi.fn();
|
||||||
|
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
|
||||||
|
expect(range()).toBe("590-600");
|
||||||
|
const [time, final] = onSeek.mock.lastCall!;
|
||||||
|
expect(final).toBe(true);
|
||||||
|
expect(time).toBeLessThan(600);
|
||||||
|
expect(time).toBeGreaterThan(599);
|
||||||
|
});
|
||||||
|
|
||||||
it("marks an end at the playhead", () => {
|
it("marks an end at the playhead", () => {
|
||||||
render(<Harness current={115.5} />);
|
render(<Harness current={115.5} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
|
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||||
import { api, type ClipRequest } from "../lib/api";
|
import { api, type ClipRequest } from "../lib/api";
|
||||||
|
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { navigateTo } from "../lib/nav";
|
import { navigateTo } from "../lib/nav";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
import { formatTimecode, parseTimecode } from "../lib/timecode";
|
import { formatTimecode } from "../lib/timecode";
|
||||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
||||||
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
||||||
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
||||||
@@ -17,6 +18,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
|
|||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
|
import { TimecodeField } from "./ui/TimecodeField";
|
||||||
|
|
||||||
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
// 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
|
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
||||||
@@ -81,9 +83,15 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NUDGE_S = 0.1;
|
const NUDGE_S = 0.1;
|
||||||
|
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
|
||||||
|
// moment, not past it — a player put at its end ENDS (a Plex film closes, a YouTube queue steps on)
|
||||||
|
// and takes the clip with it.
|
||||||
|
const LAST_MOMENT_S = 0.25;
|
||||||
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
|
// 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.
|
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
||||||
|
// A focused end is ringed in amber too, as the crop frame is: the accent ring was under 3:1 there.
|
||||||
const PLAYHEAD = "bg-warn";
|
const PLAYHEAD = "bg-warn";
|
||||||
|
const END_RING = "focus-visible:outline-warn";
|
||||||
|
|
||||||
export default function ClipPanel({
|
export default function ClipPanel({
|
||||||
src,
|
src,
|
||||||
@@ -138,15 +146,19 @@ export default function ClipPanel({
|
|||||||
if (looping && current >= clip.end) restart();
|
if (looping && current >= clip.end) restart();
|
||||||
}, [looping, current, clip.end]);
|
}, [looping, current, clip.end]);
|
||||||
|
|
||||||
|
const seek = (time: number, final: boolean) =>
|
||||||
|
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
|
||||||
const moveIn = (time: number, final: boolean) => {
|
const moveIn = (time: number, final: boolean) => {
|
||||||
setFrozen(final ? null : (frozen ?? view));
|
setFrozen(final ? null : (frozen ?? view));
|
||||||
onChange(markIn(clip, time, duration));
|
const next = markIn(clip, time, duration);
|
||||||
onSeek(time, final);
|
onChange(next);
|
||||||
|
seek(next.start, final);
|
||||||
};
|
};
|
||||||
const moveOut = (time: number, final: boolean) => {
|
const moveOut = (time: number, final: boolean) => {
|
||||||
setFrozen(final ? null : (frozen ?? view));
|
setFrozen(final ? null : (frozen ?? view));
|
||||||
onChange(markOut(clip, time, duration));
|
const next = markOut(clip, time, duration);
|
||||||
onSeek(time, final);
|
onChange(next);
|
||||||
|
seek(next.end, final);
|
||||||
};
|
};
|
||||||
|
|
||||||
const body: ClipRequest = {
|
const body: ClipRequest = {
|
||||||
@@ -210,7 +222,7 @@ export default function ClipPanel({
|
|||||||
duration={duration}
|
duration={duration}
|
||||||
playhead={current}
|
playhead={current}
|
||||||
playheadClassName={PLAYHEAD}
|
playheadClassName={PLAYHEAD}
|
||||||
onSeek={(time, final) => final && onSeek(time, true)}
|
onSeek={(time, final) => final && seek(time, true)}
|
||||||
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
|
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
|
||||||
regions={[
|
regions={[
|
||||||
{
|
{
|
||||||
@@ -233,7 +245,8 @@ export default function ClipPanel({
|
|||||||
view={view}
|
view={view}
|
||||||
playhead={current}
|
playhead={current}
|
||||||
playheadClassName={PLAYHEAD}
|
playheadClassName={PLAYHEAD}
|
||||||
onSeek={onSeek}
|
onSeek={seek}
|
||||||
|
handleRingClassName={END_RING}
|
||||||
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
||||||
background={
|
background={
|
||||||
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
||||||
@@ -276,7 +289,7 @@ export default function ClipPanel({
|
|||||||
<MarkField
|
<MarkField
|
||||||
label={t("clip.in")}
|
label={t("clip.in")}
|
||||||
value={clip.start}
|
value={clip.start}
|
||||||
text={tc(clip.start)}
|
hours={hours}
|
||||||
onSet={(v) => moveIn(v, true)}
|
onSet={(v) => moveIn(v, true)}
|
||||||
nudge={NUDGE_S}
|
nudge={NUDGE_S}
|
||||||
hereLabel={t("clip.inHere")}
|
hereLabel={t("clip.inHere")}
|
||||||
@@ -285,7 +298,7 @@ export default function ClipPanel({
|
|||||||
<MarkField
|
<MarkField
|
||||||
label={t("clip.out")}
|
label={t("clip.out")}
|
||||||
value={clip.end}
|
value={clip.end}
|
||||||
text={tc(clip.end)}
|
hours={hours}
|
||||||
onSet={(v) => moveOut(v, true)}
|
onSet={(v) => moveOut(v, true)}
|
||||||
nudge={NUDGE_S}
|
nudge={NUDGE_S}
|
||||||
hereLabel={t("clip.outHere")}
|
hereLabel={t("clip.outHere")}
|
||||||
@@ -353,7 +366,7 @@ export default function ClipPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-48 flex-1"
|
className="min-w-48 flex-1"
|
||||||
value={name}
|
value={name}
|
||||||
maxLength={255}
|
maxLength={DISPLAY_NAME_MAX}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder={t("clip.namePlaceholder")}
|
placeholder={t("clip.namePlaceholder")}
|
||||||
aria-label={t("clip.name")}
|
aria-label={t("clip.name")}
|
||||||
@@ -374,7 +387,7 @@ export default function ClipPanel({
|
|||||||
function MarkField({
|
function MarkField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
text,
|
hours,
|
||||||
onSet,
|
onSet,
|
||||||
nudge,
|
nudge,
|
||||||
hereLabel,
|
hereLabel,
|
||||||
@@ -382,17 +395,13 @@ function MarkField({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
text: string;
|
hours: boolean;
|
||||||
onSet: (v: number) => void;
|
onSet: (v: number) => void;
|
||||||
nudge: number;
|
nudge: number;
|
||||||
hereLabel: string;
|
hereLabel: string;
|
||||||
onHere: () => void;
|
onHere: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const commit = (raw: string) => {
|
|
||||||
const v = parseTimecode(raw);
|
|
||||||
if (v != null && Math.abs(v - value) > 1e-3) onSet(v);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
||||||
<span className="mr-1 text-muted">{label}</span>
|
<span className="mr-1 text-muted">{label}</span>
|
||||||
@@ -404,20 +413,13 @@ function MarkField({
|
|||||||
>
|
>
|
||||||
<Minus />
|
<Minus />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Input
|
<TimecodeField
|
||||||
// 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"
|
size="sm"
|
||||||
className="w-24 text-center tabular-nums"
|
className="w-24 text-center tabular-nums"
|
||||||
defaultValue={text}
|
value={value}
|
||||||
|
hours={hours}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onBlur={(e) => commit(e.target.value)}
|
onCommit={onSet}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Enter") return;
|
|
||||||
e.preventDefault();
|
|
||||||
commit(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ beforeAll(() => {
|
|||||||
const clock = { current: 0, duration: 3000 };
|
const clock = { current: 0, duration: 3000 };
|
||||||
const started: { videoId: string; start: number | undefined }[] = [];
|
const started: { videoId: string; start: number | undefined }[] = [];
|
||||||
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
|
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
|
||||||
let playerEvents: { onReady?: () => void } = {};
|
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
|
||||||
const base = {
|
const base = {
|
||||||
getCurrentTime: () => clock.current,
|
getCurrentTime: () => clock.current,
|
||||||
getDuration: () => clock.duration,
|
getDuration: () => clock.duration,
|
||||||
@@ -65,7 +65,7 @@ const fakePlayer = new Proxy(base, {
|
|||||||
window.YT = {
|
window.YT = {
|
||||||
Player: function (
|
Player: function (
|
||||||
_el: unknown,
|
_el: unknown,
|
||||||
opts: { videoId: string; playerVars: { start?: number }; events?: { onReady?: () => void } },
|
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
|
||||||
) {
|
) {
|
||||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||||
playerEvents = opts.events ?? {};
|
playerEvents = opts.events ?? {};
|
||||||
@@ -691,6 +691,27 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => {
|
||||||
|
wideWindow(true);
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
void window.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
|
||||||
|
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
|
||||||
|
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
|
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
|
||||||
wideWindow(true);
|
wideWindow(true);
|
||||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
@@ -953,4 +974,61 @@ describe("PlayerModal — clip mode", () => {
|
|||||||
key("Escape");
|
key("Escape");
|
||||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
key("f");
|
||||||
|
key("c");
|
||||||
|
key("Escape");
|
||||||
|
const stage = document.querySelector(".player-stage--max")!;
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement?.getAttribute("aria-label")).toBe("clip.open"),
|
||||||
|
);
|
||||||
|
// The card's own scissors lies under the maximised stage: a Space there reopened the panel.
|
||||||
|
expect(stage.contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps what was typed in the panel when the view is maximised under it", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
key("c");
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
|
||||||
|
target: { value: "The bit" },
|
||||||
|
});
|
||||||
|
const loop = screen.getByRole("button", { name: "clip.loop" });
|
||||||
|
fireEvent.click(loop);
|
||||||
|
act(() => loop.focus());
|
||||||
|
const panel = screen.getByTestId("clip-panel");
|
||||||
|
key("f");
|
||||||
|
expect(document.querySelector(".player-stage--max")!.contains(panel)).toBe(true);
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBe(panel);
|
||||||
|
expect((screen.getByRole("textbox", { name: "clip.name" }) as HTMLInputElement).value).toBe(
|
||||||
|
"The bit",
|
||||||
|
);
|
||||||
|
expect(loop.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(document.activeElement).toBe(loop);
|
||||||
|
// And back: the dock the panel sat in goes, the panel and its focus stay.
|
||||||
|
key("f");
|
||||||
|
expect(document.querySelector(".player-stage--max")).toBeNull();
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBe(panel);
|
||||||
|
expect(document.activeElement).toBe(loop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not step on to the next video when one ends while a clip is marked", async () => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
const B = vid("B", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
// Auto-advance on: an ending video steps on to B.
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: /^player\.autoAdvance\.label/ })[0]!);
|
||||||
|
key("c");
|
||||||
|
act(() => playerEvents.onStateChange?.({ data: 0 }));
|
||||||
|
await settle();
|
||||||
|
expect(lastStart()?.videoId).toBe("A");
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
import { markInAt, markOutAt, 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 {
|
||||||
@@ -371,6 +371,41 @@ export default function PlayerModal({
|
|||||||
const clipDockRef = useRef<HTMLDivElement>(null);
|
const clipDockRef = useRef<HTMLDivElement>(null);
|
||||||
const [clipDockH, setClipDockH] = useState(0);
|
const [clipDockH, setClipDockH] = useState(0);
|
||||||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
const clipDocked = clipOpen && (maximized || isFullscreen);
|
||||||
|
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
||||||
|
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
||||||
|
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
||||||
|
const [clipHost] = useState(() => document.createElement("div"));
|
||||||
|
const clipSlotRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The control focused in the panel. A move is a remove and an insert, and the focus goes with
|
||||||
|
// neither — leaving the big view, React has already removed the dock (and the host in it) before
|
||||||
|
// the move below runs, so the focus is on <body> by then and only this still knows where it was.
|
||||||
|
const clipFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const onIn = (e: FocusEvent) => {
|
||||||
|
clipFocusRef.current = e.target instanceof HTMLElement ? e.target : null;
|
||||||
|
};
|
||||||
|
const onOut = (e: FocusEvent) => {
|
||||||
|
// Left for somewhere else (not a removal, which fires nothing).
|
||||||
|
if (e.relatedTarget instanceof Node && !clipHost.contains(e.relatedTarget))
|
||||||
|
clipFocusRef.current = null;
|
||||||
|
};
|
||||||
|
clipHost.addEventListener("focusin", onIn);
|
||||||
|
clipHost.addEventListener("focusout", onOut);
|
||||||
|
return () => {
|
||||||
|
clipHost.removeEventListener("focusin", onIn);
|
||||||
|
clipHost.removeEventListener("focusout", onOut);
|
||||||
|
};
|
||||||
|
}, [clipHost]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const slot = clipDocked ? clipDockRef.current : clipSlotRef.current;
|
||||||
|
if (!clipOpen || !slot || clipHost.parentElement === slot) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const lost = active == null || active === document.body;
|
||||||
|
const focused = clipHost.contains(active) ? active : lost ? clipFocusRef.current : null;
|
||||||
|
slot.appendChild(clipHost);
|
||||||
|
if (focused instanceof HTMLElement && clipHost.contains(focused))
|
||||||
|
focused.focus({ preventScroll: true });
|
||||||
|
}, [clipOpen, clipDocked, clipHost]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = clipDockRef.current;
|
const el = clipDockRef.current;
|
||||||
if (!clipDocked || !el) return;
|
if (!clipDocked || !el) return;
|
||||||
@@ -396,9 +431,14 @@ export default function PlayerModal({
|
|||||||
const iv = window.setInterval(read, 200);
|
const iv = window.setInterval(read, 200);
|
||||||
return () => window.clearInterval(iv);
|
return () => window.clearInterval(iv);
|
||||||
}, [clipOpen]);
|
}, [clipOpen]);
|
||||||
// The scissors that opened the panel gets the focus back when it closes.
|
// The scissors gets the focus back when the panel closes: the one the user can see (the docked
|
||||||
|
// bar's in the big views — the card's lies under the maximised stage, and a Space on it reopened the
|
||||||
|
// panel), else the player itself (the card's is not there in a shut wide panel or for a video opened
|
||||||
|
// from a link). Also when the panel went with its video (a queue step), not only when closed.
|
||||||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const clipDockButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const refocusClip = useRef(false);
|
const refocusClip = useRef(false);
|
||||||
|
const clipOpenRef = useLatest(clipOpen);
|
||||||
const closeClip = () => {
|
const closeClip = () => {
|
||||||
refocusClip.current = true;
|
refocusClip.current = true;
|
||||||
setClip(null);
|
setClip(null);
|
||||||
@@ -408,10 +448,18 @@ export default function PlayerModal({
|
|||||||
setShutForClip(false);
|
setShutForClip(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
// Read at the moment the panel closes: the view then decides, a later F is not a close.
|
||||||
if (clipOpen || !refocusClip.current) return;
|
const refocusAfterClip = useEffectEvent(() => {
|
||||||
|
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||||
|
if (!refocusClip.current && !lost) return;
|
||||||
refocusClip.current = false;
|
refocusClip.current = false;
|
||||||
clipButtonRef.current?.focus();
|
const big = maximized || isFullscreen;
|
||||||
|
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
|
||||||
|
if (target) target.focus();
|
||||||
|
else focusModal();
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clipOpen) refocusAfterClip();
|
||||||
}, [clipOpen]);
|
}, [clipOpen]);
|
||||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
/** 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 in the card, and in place of the docked bar when maximised; the
|
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||||||
@@ -588,6 +636,8 @@ export default function PlayerModal({
|
|||||||
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
|
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
|
||||||
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
|
||||||
const advanceOnEnd = () => {
|
const advanceOnEnd = () => {
|
||||||
|
// Marking a clip, the end is a place, not a finish: stepping on would drop the clip.
|
||||||
|
if (clipOpenRef.current) return;
|
||||||
const { autoMode: a, loopMode: l } = modeRef.current;
|
const { autoMode: a, loopMode: l } = modeRef.current;
|
||||||
if (l === "one") return replayCurrent();
|
if (l === "one") return replayCurrent();
|
||||||
const { queue: q, index: i } = navRef.current;
|
const { queue: q, index: i } = navRef.current;
|
||||||
@@ -698,9 +748,8 @@ export default function PlayerModal({
|
|||||||
if (clip) closeClip();
|
if (clip) closeClip();
|
||||||
else openClip();
|
else openClip();
|
||||||
},
|
},
|
||||||
markIn: () => markClip((r, t, d) => markIn(r ?? openAt(t, d), t, d)),
|
markIn: () => markClip(markInAt),
|
||||||
markOut: () =>
|
markOut: () => markClip(markOutAt),
|
||||||
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
|
||||||
@@ -955,9 +1004,7 @@ export default function PlayerModal({
|
|||||||
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} />}
|
||||||
{clipDocked && (
|
{clipDocked && (
|
||||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu">
|
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||||||
{clipPanel}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{(maximized || isFullscreen) && !clipDocked && (
|
{(maximized || isFullscreen) && !clipDocked && (
|
||||||
<div
|
<div
|
||||||
@@ -989,7 +1036,12 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
{/* Clip mode here: the clip panel takes this bar's place. */}
|
{/* Clip mode here: the clip panel takes this bar's place. */}
|
||||||
{playerError == null && (
|
{playerError == null && (
|
||||||
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
|
<IconButton
|
||||||
|
ref={clipDockButtonRef}
|
||||||
|
variant="overlay"
|
||||||
|
onClick={openClip}
|
||||||
|
label={t("clip.open")}
|
||||||
|
>
|
||||||
<Scissors />
|
<Scissors />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -1086,7 +1138,8 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!clipDocked && clipPanel}
|
<div ref={clipSlotRef} />
|
||||||
|
{clipPanel && createPortal(clipPanel, clipHost)}
|
||||||
</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. */}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import PlexPlayer from "./PlexPlayer";
|
import PlexPlayer from "./PlexPlayer";
|
||||||
import { api, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexItemDetail } from "../lib/api";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||||
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ function mount(opts: {
|
|||||||
queue?: string[];
|
queue?: string[];
|
||||||
first?: string;
|
first?: string;
|
||||||
fromStart?: boolean;
|
fromStart?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||||
const session = vi
|
const session = vi
|
||||||
@@ -139,7 +141,12 @@ function mount(opts: {
|
|||||||
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
|
<PlexPlayer
|
||||||
|
itemId={first}
|
||||||
|
onClose={opts.onClose ?? (() => {})}
|
||||||
|
queue={opts.queue}
|
||||||
|
fromStart={opts.fromStart}
|
||||||
|
/>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { session };
|
return { session };
|
||||||
@@ -1023,4 +1030,71 @@ describe("PlexPlayer — clip mode", () => {
|
|||||||
else delete (proto as unknown as Record<string, unknown>)[k];
|
else delete (proto as unknown as Record<string, unknown>)[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prefs = (p: object) =>
|
||||||
|
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||||
|
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
|
||||||
|
|
||||||
|
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
|
||||||
|
const full = { ...item("a"), cast: [], genres: [], directors: [], collections: [] };
|
||||||
|
mount({ details: { a: full as PlexItemDetail } });
|
||||||
|
await playingAt("/media/a", 120);
|
||||||
|
key("c");
|
||||||
|
key("i");
|
||||||
|
expect(infoOverlay()).toBeTruthy();
|
||||||
|
key("Escape");
|
||||||
|
expect(infoOverlay()).toBeNull();
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
|
key("Escape");
|
||||||
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("⌫ under a dialog over the player is the dialog's, not the clip's", async () => {
|
||||||
|
mount({ details: { a: item("a") } });
|
||||||
|
await playingAt("/media/a", 120);
|
||||||
|
key("c");
|
||||||
|
const layer = pushLayer();
|
||||||
|
try {
|
||||||
|
key("Backspace");
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
removeLayer(layer);
|
||||||
|
}
|
||||||
|
key("Backspace");
|
||||||
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens no range while the item's length is unknown", async () => {
|
||||||
|
mount({
|
||||||
|
details: { a: { ...item("a"), duration_seconds: null } as unknown as PlexItemDetail },
|
||||||
|
});
|
||||||
|
await playingAt("/media/a", 120);
|
||||||
|
for (const k of ["c", "[", "]"]) key(k);
|
||||||
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an item played to its end while a clip is marked stays, with the clip", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
mount({ details: { a: item("a") }, onClose });
|
||||||
|
await playingAt("/media/a", 2990);
|
||||||
|
key("c");
|
||||||
|
act(() => void video().dispatchEvent(new Event("ended")));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
|
||||||
|
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||||
|
const withCredits = {
|
||||||
|
...item("a", 0, "b"),
|
||||||
|
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
|
||||||
|
} as unknown as PlexItemDetail;
|
||||||
|
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
|
||||||
|
await playingAt("/media/a", 2800);
|
||||||
|
key("c");
|
||||||
|
await playingAt("/media/a", 2910);
|
||||||
|
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||||
|
expect(lastStart(session)?.[0]).toBe("a");
|
||||||
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
import { markInAt, markOutAt, 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";
|
||||||
@@ -833,6 +833,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
seekTo,
|
seekTo,
|
||||||
videoRef,
|
videoRef,
|
||||||
reportWatchedError,
|
reportWatchedError,
|
||||||
|
// Marking a clip, the viewer is where they want to be: a skip would carry the player off the
|
||||||
|
// range (an intro clip skipped past its Out, the credits' leaving for the next item).
|
||||||
|
suspended: clip != null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||||
@@ -902,6 +905,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
|
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
|
||||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||||
if (absRef.current <= 0) return;
|
if (absRef.current <= 0) return;
|
||||||
|
// Marking a clip, the end is a place, not a finish: closing or moving on would drop the clip.
|
||||||
|
if (clipRef.current) return;
|
||||||
const watchedWrite = api.plexSetState(id, "watched");
|
const watchedWrite = api.plexSetState(id, "watched");
|
||||||
watchedWrite.catch(reportWatchedError);
|
watchedWrite.catch(reportWatchedError);
|
||||||
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
|
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
|
||||||
@@ -918,7 +923,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
};
|
};
|
||||||
video.addEventListener("ended", onEnded);
|
video.addEventListener("ended", onEnded);
|
||||||
return () => video.removeEventListener("ended", onEnded);
|
return () => video.removeEventListener("ended", onEnded);
|
||||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef]);
|
}, [id, nextId, go, onClose, finishProgress, reportWatchedError, absRef, upNextPrefRef, clipRef]);
|
||||||
|
|
||||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||||
@@ -937,21 +942,25 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
|
|
||||||
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
|
// The ONE Back cascade for the player's own ways of saying "back": the on-screen arrow, ⌫ and
|
||||||
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
|
// Escape. It peels off what is layered over the video — the auto-skip countdown, then the open
|
||||||
// panel, then the stats readout — and only then leaves the player. The mouse/browser Back peels
|
// panel, then the stats readout, then the clip being marked (its crop first) — and only then leaves
|
||||||
// the layers the viewer OPENED one per press, the one opened last first (each holds its own history
|
// the player. The mouse/browser Back peels the layers the viewer OPENED one per press, the one
|
||||||
// entry, see BackClose below); the ones that open by themselves have none, so Back leaves past
|
// opened last first (each holds its own history entry, see BackClose below); the ones that open by
|
||||||
// them. The shortcuts sheet is not in it: a Modal above us, with its own Escape layer and
|
// themselves have none, so Back leaves past them. The shortcuts sheet is not in it: a Modal above
|
||||||
// Back-to-close. Leaving closes whatever is still up first (`useHistorySubview`'s back).
|
// us, with its own Escape layer and Back-to-close. Leaving closes whatever is still up first
|
||||||
|
// (`useHistorySubview`'s back).
|
||||||
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
|
// - The on-screen arrow is a click on "leave", not a cancel, so it does not stop a countdown.
|
||||||
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
|
// - Escape in fullscreen leaves fullscreen (the browser's own Escape) and not the player.
|
||||||
|
// - The arrow leaves past a clip as it does past a panel; the keys step out of it.
|
||||||
const back = useCallback(
|
const back = useCallback(
|
||||||
(from: "arrow" | "key" | "escape") => {
|
(from: "arrow" | "key" | "escape") => {
|
||||||
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
if (from !== "arrow" && skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||||
else if (panelRef.current !== "none") setPanel("none");
|
else if (panelRef.current !== "none") setPanel("none");
|
||||||
else if (statsOpenRef.current) setStatsOpen(false);
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
else if (from !== "escape" || !fullscreenElement()) onClose();
|
else if (from === "escape" && fullscreenElement()) return;
|
||||||
|
else if (from !== "arrow" && clipRef.current) stepOutOfClip();
|
||||||
|
else onClose();
|
||||||
},
|
},
|
||||||
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef],
|
[onClose, panelRef, statsOpenRef, skipProgressRef, cancelAutoSkipRef, clipRef, stepOutOfClip],
|
||||||
);
|
);
|
||||||
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
|
// The mouse/browser Back closes the layer whose history entry it popped (see BackClose below).
|
||||||
const closeStats = useCallback(() => setStatsOpen(false), []);
|
const closeStats = useCallback(() => setStatsOpen(false), []);
|
||||||
@@ -1072,19 +1081,20 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
},
|
},
|
||||||
info: () => togglePanel("info"),
|
info: () => togglePanel("info"),
|
||||||
stats: () => setStatsOpen((v) => !v),
|
stats: () => setStatsOpen((v) => !v),
|
||||||
|
// A range needs the item's length (unknown while its detail loads, or never given).
|
||||||
clip: () => {
|
clip: () => {
|
||||||
if (clipRef.current) closeClip();
|
if (clipRef.current) closeClip();
|
||||||
else setClip(openAt(absRef.current, durationRef.current));
|
else if (durationRef.current > 0) setClip(openAt(absRef.current, durationRef.current));
|
||||||
},
|
},
|
||||||
markIn: () => {
|
markIn: () => {
|
||||||
const t = absRef.current;
|
const t = absRef.current;
|
||||||
const d = durationRef.current;
|
const d = durationRef.current;
|
||||||
setClip((c) => markIn(c ?? openAt(t, d), t, d));
|
if (d > 0) setClip((c) => markInAt(c, t, d));
|
||||||
},
|
},
|
||||||
markOut: () => {
|
markOut: () => {
|
||||||
const t = absRef.current;
|
const t = absRef.current;
|
||||||
const d = durationRef.current;
|
const d = durationRef.current;
|
||||||
setClip((c) => markOut(c ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d));
|
if (d > 0) setClip((c) => markOutAt(c, 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
|
||||||
@@ -1097,15 +1107,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
|
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
|
||||||
// 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. The clip panel pushes no
|
||||||
const ownPanelOpen =
|
// layer, so with it open a layer above is never the player's own (a refused Cut opens the
|
||||||
panelRef.current !== "none" || statsOpenRef.current || clipRef.current != null;
|
// ErrorDialog right over it): the clip is in the cascade, behind that layer.
|
||||||
|
const ownPanelOpen = panelRef.current !== "none" || statsOpenRef.current;
|
||||||
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
|
||||||
return;
|
return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// The clip panel is the player's own too, and closes before the player does.
|
back("key");
|
||||||
if (clipRef.current) stepOutOfClip();
|
|
||||||
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
|
||||||
@@ -1115,8 +1124,7 @@ 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;
|
||||||
if (clipRef.current) stepOutOfClip();
|
back("escape");
|
||||||
else back("escape");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
||||||
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
|
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
|
||||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
@@ -124,3 +124,39 @@ describe("VideoEditor — a cut on the keyboard", () => {
|
|||||||
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
|
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
|
||||||
|
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
|
||||||
|
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
|
||||||
|
// hit area that reaches into its segment, so a press on it is the toggle's.
|
||||||
|
describe("VideoEditor — a cut's remove button", () => {
|
||||||
|
const split = () => {
|
||||||
|
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"));
|
||||||
|
};
|
||||||
|
|
||||||
|
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
|
||||||
|
split();
|
||||||
|
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||||
|
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
|
||||||
|
fireEvent.click(remove);
|
||||||
|
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
|
||||||
|
split();
|
||||||
|
const remove = screen.getByRole("button", { name: "editor.removeCut" });
|
||||||
|
expect(remove.className).toMatch(/\bbottom-0\b/);
|
||||||
|
const track = screen.getByRole("group", { name: "editor.timeline" });
|
||||||
|
const toggles = within(track).getAllByRole("button", { pressed: true });
|
||||||
|
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { markReported } from "../lib/api/core";
|
|||||||
import { IconButton } from "./ui/IconButton";
|
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 { TimecodeField } from "./ui/TimecodeField";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { RangeTrack } from "./ui/RangeTrack";
|
import { RangeTrack } from "./ui/RangeTrack";
|
||||||
import { formatTimecode, parseTimecode } from "../lib/timecode";
|
import { formatTimecode } from "../lib/timecode";
|
||||||
|
|
||||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||||
|
|
||||||
@@ -385,7 +386,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
|
||||||
onPointerDown: () => setSelId(s.id),
|
onPointerDown: () => setSelId(s.id),
|
||||||
children: (
|
children: (
|
||||||
// keep/drop toggle
|
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
|
||||||
|
// segment): a press on the eye is the eye's, never a drag of the cut
|
||||||
<Pressable
|
<Pressable
|
||||||
aria-pressed={s.keep}
|
aria-pressed={s.keep}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
@@ -393,7 +395,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
toggleKeep(s.id);
|
toggleKeep(s.id);
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-1 left-1 p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
"absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
|
||||||
s.keep ? "text-accent" : "text-muted",
|
s.keep ? "text-accent" : "text-muted",
|
||||||
)}
|
)}
|
||||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||||
@@ -413,13 +415,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
bigStep: 1,
|
bigStep: 1,
|
||||||
onChange: (time: number) => moveBoundary(bi, time),
|
onChange: (time: number) => moveBoundary(bi, time),
|
||||||
|
// At the cut's foot, clear of the next segment's eye at its head. A real button: a
|
||||||
|
// click or Enter removes the cut; its press is not a seek of the track under it.
|
||||||
adornment: (
|
adornment: (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
onClick={() => deleteBoundary(bi)}
|
||||||
deleteBoundary(bi);
|
className="pointer-events-auto absolute bottom-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
||||||
}}
|
aria-label={t("editor.removeCut")}
|
||||||
className="absolute top-0 -right-0.5 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
|
|
||||||
title={t("editor.removeCut")}
|
title={t("editor.removeCut")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
@@ -465,30 +468,24 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<label className="flex items-center gap-1 text-muted">
|
<label className="flex items-center gap-1 text-muted">
|
||||||
{t("editor.in")}
|
{t("editor.in")}
|
||||||
<Input
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
defaultValue={tc(sel.start)}
|
value={sel.start}
|
||||||
key={`s-${sel.id}-${sel.start}`}
|
hours={duration >= 3600}
|
||||||
disabled={selIdx === 0}
|
disabled={selIdx === 0}
|
||||||
onBlur={(e) => {
|
onCommit={(v) => selIdx > 0 && moveBoundary(selIdx - 1, v)}
|
||||||
const v = parseTimecode(e.target.value);
|
|
||||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1 text-muted">
|
<label className="flex items-center gap-1 text-muted">
|
||||||
{t("editor.out")}
|
{t("editor.out")}
|
||||||
<Input
|
<TimecodeField
|
||||||
className="w-24 tabular-nums"
|
className="w-24 tabular-nums"
|
||||||
size="sm"
|
size="sm"
|
||||||
defaultValue={tc(sel.end)}
|
value={sel.end}
|
||||||
key={`e-${sel.id}-${sel.end}`}
|
hours={duration >= 3600}
|
||||||
disabled={selIdx === segments.length - 1}
|
disabled={selIdx === segments.length - 1}
|
||||||
onBlur={(e) => {
|
onCommit={(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v)}
|
||||||
const v = parseTimecode(e.target.value);
|
|
||||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
<span className="text-xs text-muted tabular-nums w-16 text-right">
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ describe("CropFrame", () => {
|
|||||||
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows the pointer that took it, not a second finger landing on it", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||||
|
fireEvent.pointerDown(frame, { button: 0, clientX: 500, clientY: 300, pointerId: 1 });
|
||||||
|
// A second finger lands 200 px away and stays: it must not become the drag's origin.
|
||||||
|
fireEvent.pointerDown(frame, { button: 0, clientX: 700, clientY: 300, pointerId: 2 });
|
||||||
|
fireEvent.pointerMove(frame, { clientX: 660, clientY: 390, pointerId: 1 });
|
||||||
|
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||||
|
fireEvent.pointerMove(frame, { clientX: 900, clientY: 300, pointerId: 2 });
|
||||||
|
expect(rect()).toBe("0.300 0.300 0.400 0.400");
|
||||||
|
fireEvent.pointerUp(frame, { clientX: 660, clientY: 390, pointerId: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
it("takes the focus on a press, so the arrows go on from where the pointer left it", () => {
|
it("takes the focus on a press, so the arrows go on from where the pointer left it", () => {
|
||||||
render(<Harness />);
|
render(<Harness />);
|
||||||
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
const frame = screen.getByRole("group", { name: "common.cropFrame.frame" });
|
||||||
@@ -116,6 +129,7 @@ describe("CropFrame", () => {
|
|||||||
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
|
const se = screen.getByRole("group", { name: "common.cropFrame.se" });
|
||||||
fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 });
|
fireEvent.pointerDown(se, { button: 0, clientX: 960, clientY: 540 });
|
||||||
expect(document.activeElement).toBe(se);
|
expect(document.activeElement).toBe(se);
|
||||||
|
fireEvent.pointerUp(se, { clientX: 960, clientY: 540 });
|
||||||
// An edge is the pointer's only: its press puts the focus on the frame it belongs to.
|
// An edge is the pointer's only: its press puts the focus on the frame it belongs to.
|
||||||
fireEvent.pointerDown(document.querySelector<HTMLElement>(".cursor-ew-resize")!, {
|
fireEvent.pointerDown(document.querySelector<HTMLElement>(".cursor-ew-resize")!, {
|
||||||
button: 0,
|
button: 0,
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ const SMALL_HANDLE_BOX: Record<CropHandle, string> = {
|
|||||||
// Below this many px on its shorter side a frame takes the small handles.
|
// Below this many px on its shorter side a frame takes the small handles.
|
||||||
const SMALL_FRAME_PX = 64;
|
const SMALL_FRAME_PX = 64;
|
||||||
|
|
||||||
type Drag = { handle: CropHandle | "move"; x: number; y: number; from: Frac };
|
// A drag is its pointer's: a second finger on the frame neither restarts it from where it landed nor
|
||||||
|
// moves it (two origins at once made the frame jump between them).
|
||||||
|
type Drag = { handle: CropHandle | "move"; pointer: number; x: number; y: number; from: Frac };
|
||||||
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
|
// Not the app's accent ring: over a video it can be any colour, and a light theme's dark-blue accent
|
||||||
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
|
// all but vanished on a dark picture. Amber, like the players' playhead, reads on both.
|
||||||
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn";
|
||||||
@@ -106,6 +108,7 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
|||||||
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
|
// The press is the frame's: not the video's play / pause, not a scrub of anything under it.
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (drag) return;
|
||||||
// preventDefault also stops the press from focusing: focused by hand, so the arrows go on from
|
// preventDefault also stops the press from focusing: focused by hand, so the arrows go on from
|
||||||
// here (an edge is the pointer's only, so its frame takes the focus).
|
// here (an edge is the pointer's only, so its frame takes the focus).
|
||||||
e.currentTarget.closest<HTMLElement>("[tabindex]")?.focus({ preventScroll: true });
|
e.currentTarget.closest<HTMLElement>("[tabindex]")?.focus({ preventScroll: true });
|
||||||
@@ -114,10 +117,10 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
|||||||
} catch {
|
} catch {
|
||||||
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
|
// No capture (a synthetic event): the drag still follows while the pointer stays on it.
|
||||||
}
|
}
|
||||||
setDrag({ handle, x: e.clientX, y: e.clientY, from: rect });
|
setDrag({ handle, pointer: e.pointerId, x: e.clientX, y: e.clientY, from: rect });
|
||||||
};
|
};
|
||||||
const follow = (e: PointerEvent<HTMLElement>) => {
|
const follow = (e: PointerEvent<HTMLElement>) => {
|
||||||
if (!drag || !pic) return;
|
if (!drag || drag.pointer !== e.pointerId || !pic) return;
|
||||||
const dx = (e.clientX - drag.x) / pic.width;
|
const dx = (e.clientX - drag.x) / pic.width;
|
||||||
const dy = (e.clientY - drag.y) / pic.height;
|
const dy = (e.clientY - drag.y) / pic.height;
|
||||||
onChange(
|
onChange(
|
||||||
@@ -126,7 +129,9 @@ export function CropFrame({ aspect, rect, ratio, onChange, className }: Props) {
|
|||||||
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
|
: resizeCrop(drag.from, drag.handle, dx, dy, ratio),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const end = () => setDrag(null);
|
const end = (e: PointerEvent<HTMLElement>) => {
|
||||||
|
if (drag?.pointer === e.pointerId) setDrag(null);
|
||||||
|
};
|
||||||
const pointer = (handle: Drag["handle"]) => ({
|
const pointer = (handle: Drag["handle"]) => ({
|
||||||
onPointerDown: begin(handle),
|
onPointerDown: begin(handle),
|
||||||
onPointerMove: follow,
|
onPointerMove: follow,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
| 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`, `TimecodeField` (a cut's time typed in: the draft until Enter or blur takes it, then the value as it landed; unreadable stays, marked invalid), `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), `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), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
| 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), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||||
| 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` |
|
||||||
|
|||||||
@@ -110,6 +110,60 @@ describe("RangeTrack", () => {
|
|||||||
expect(onSeek).not.toHaveBeenCalled();
|
expect(onSeek).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a drag the browser took away (a cancel) as final where it stopped", () => {
|
||||||
|
// A caller that acts on the final move only (the Plex seek, the clip strip's window) was left
|
||||||
|
// with a not-final one for good.
|
||||||
|
const h = handle();
|
||||||
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
||||||
|
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 });
|
||||||
|
fireEvent.pointerCancel(s, { pointerId: 1 });
|
||||||
|
expect(h.onChange).toHaveBeenLastCalledWith(70, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the pointer that took a handle, not a second finger", () => {
|
||||||
|
const a = handle({ id: "in", label: "In", time: 30 });
|
||||||
|
const b = handle({ id: "out", label: "Out", time: 70 });
|
||||||
|
render(<RangeTrack duration={100} label="Clip" handles={[a, b]} />);
|
||||||
|
box(screen.getByRole("group", { name: "Clip" }));
|
||||||
|
const inH = screen.getByRole("slider", { name: "In" });
|
||||||
|
const outH = screen.getByRole("slider", { name: "Out" });
|
||||||
|
fireEvent.pointerDown(inH, { button: 0, clientX: 300, pointerId: 1 });
|
||||||
|
fireEvent.pointerDown(outH, { button: 0, clientX: 700, pointerId: 2 });
|
||||||
|
fireEvent.pointerMove(inH, { clientX: 400, pointerId: 1 });
|
||||||
|
expect(a.onChange).toHaveBeenLastCalledWith(40, false);
|
||||||
|
fireEvent.pointerMove(outH, { clientX: 800, pointerId: 2 });
|
||||||
|
expect(b.onChange).not.toHaveBeenCalled();
|
||||||
|
fireEvent.pointerUp(inH, { clientX: 400, pointerId: 1 });
|
||||||
|
expect(a.onChange).toHaveBeenLastCalledWith(40, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a handle's adornment beside its slider, not inside it, where a button stays a button", () => {
|
||||||
|
const h = handle({ adornment: <button>Remove</button> });
|
||||||
|
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Remove" });
|
||||||
|
expect(screen.getByRole("slider", { name: "In" }).contains(button)).toBe(false);
|
||||||
|
// The slider's arrows are not the button's.
|
||||||
|
fireEvent.keyDown(button, { key: "ArrowLeft" });
|
||||||
|
expect(h.onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rings a focused handle in the caller's colour, for a track on the dark media chrome", () => {
|
||||||
|
render(
|
||||||
|
<RangeTrack
|
||||||
|
duration={100}
|
||||||
|
label="Clip"
|
||||||
|
handles={[handle()]}
|
||||||
|
handleRingClassName="focus-visible:outline-warn"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("slider", { name: "In" }).className).toContain(
|
||||||
|
"focus-visible:outline-warn",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("seeks on a press of the bare track, following the drag, final on release", () => {
|
it("seeks on a press of the bare track, following the drag, final on release", () => {
|
||||||
const onSeek = vi.fn();
|
const onSeek = vi.fn();
|
||||||
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
|
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -18,8 +19,11 @@ import clsx from "clsx";
|
|||||||
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
|
// 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.
|
// 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
|
// Every move reports `final`: false while a pointer drags, true on release or a key — and when the
|
||||||
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
|
// browser takes the pointer away mid-drag (a cancel: a system gesture on a tablet), true where it
|
||||||
|
// stopped. A player whose seek is expensive (a transcode restarts) acts on the final one only; a cheap
|
||||||
|
// one follows the drag. A drag is its pointer's: a second finger neither takes it over nor starts
|
||||||
|
// another, so two fingers on the track never fight over one mark.
|
||||||
|
|
||||||
export interface RangeWindow {
|
export interface RangeWindow {
|
||||||
from: number;
|
from: number;
|
||||||
@@ -42,7 +46,9 @@ export interface TrackHandle {
|
|||||||
onChange: (time: number, final: boolean) => void;
|
onChange: (time: number, final: boolean) => void;
|
||||||
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
||||||
barClassName?: string;
|
barClassName?: string;
|
||||||
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
|
/** Drawn at the handle, e.g. a remove button; it should stop its own pointer events. Beside the
|
||||||
|
* slider, not in it: a slider's content is not a control to assistive tech, and its keys are the
|
||||||
|
* slider's. */
|
||||||
adornment?: ReactNode;
|
adornment?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,9 @@ interface Props {
|
|||||||
/** Accessible name of the whole track. */
|
/** Accessible name of the whole track. */
|
||||||
label: string;
|
label: string;
|
||||||
playheadClassName?: string;
|
playheadClassName?: string;
|
||||||
|
/** A focused handle's ring colour (a `focus-visible:outline-*` class) where the theme accent does
|
||||||
|
* not read on the track, e.g. on the dark media chrome. */
|
||||||
|
handleRingClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
@@ -98,6 +107,7 @@ export function RangeTrack({
|
|||||||
className,
|
className,
|
||||||
label,
|
label,
|
||||||
playheadClassName = "bg-white",
|
playheadClassName = "bg-white",
|
||||||
|
handleRingClassName,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const win: RangeWindow = view ?? { from: 0, to: duration };
|
const win: RangeWindow = view ?? { from: 0, to: duration };
|
||||||
const span = Math.max(win.to - win.from, 1e-6);
|
const span = Math.max(win.to - win.from, 1e-6);
|
||||||
@@ -105,8 +115,11 @@ export function RangeTrack({
|
|||||||
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
|
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
|
// 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.
|
// acted on between its own down and up.
|
||||||
const [dragging, setDragging] = useState<string | null>(null);
|
const [dragging, setDragging] = useState<{ id: string; pointer: number } | null>(null);
|
||||||
const [scrubbing, setScrubbing] = useState(false);
|
const [scrubbing, setScrubbing] = useState<number | null>(null);
|
||||||
|
// Where the drag last put its handle, for a cancel to report as final.
|
||||||
|
const lastRef = useRef(0);
|
||||||
|
const busy = dragging != null || scrubbing != null;
|
||||||
|
|
||||||
const capture = (e: PointerEvent<HTMLElement>) => {
|
const capture = (e: PointerEvent<HTMLElement>) => {
|
||||||
try {
|
try {
|
||||||
@@ -123,22 +136,25 @@ export function RangeTrack({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={clsx("relative touch-none select-none overflow-hidden", className)}
|
className={clsx("relative touch-none select-none overflow-hidden", className)}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!onSeek || e.button !== 0) return;
|
if (!onSeek || e.button !== 0 || busy) return;
|
||||||
capture(e);
|
capture(e);
|
||||||
setScrubbing(true);
|
setScrubbing(e.pointerId);
|
||||||
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
const time = timeAtPointer(e.currentTarget, e.clientX, win);
|
const time = timeAtPointer(e.currentTarget, e.clientX, win);
|
||||||
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
|
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
|
||||||
if (scrubbing) onSeek?.(time, false);
|
if (scrubbing === e.pointerId) onSeek?.(time, false);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
if (!scrubbing) return;
|
if (scrubbing !== e.pointerId) return;
|
||||||
setScrubbing(false);
|
setScrubbing(null);
|
||||||
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => setScrubbing(false)}
|
// A cancelled scrub seeks no further: where it was taken away is not a place anyone chose.
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
if (scrubbing === e.pointerId) setScrubbing(null);
|
||||||
|
}}
|
||||||
onPointerLeave={() => onHover?.(null)}
|
onPointerLeave={() => onHover?.(null)}
|
||||||
>
|
>
|
||||||
{background}
|
{background}
|
||||||
@@ -171,7 +187,12 @@ export function RangeTrack({
|
|||||||
{handles.map((h) => {
|
{handles.map((h) => {
|
||||||
const f = frac(h.time);
|
const f = frac(h.time);
|
||||||
if (f < 0 || f > 1) return null;
|
if (f < 0 || f > 1) return null;
|
||||||
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
|
const move = (time: number, final: boolean) => {
|
||||||
|
const t = clamp(time, h.min, h.max);
|
||||||
|
lastRef.current = t;
|
||||||
|
h.onChange(t, final);
|
||||||
|
};
|
||||||
|
const mine = (e: PointerEvent) => dragging?.id === h.id && dragging.pointer === e.pointerId;
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
const big = e.shiftKey ? h.bigStep : h.step;
|
const big = e.shiftKey ? h.bigStep : h.step;
|
||||||
const to: Record<string, number> = {
|
const to: Record<string, number> = {
|
||||||
@@ -192,7 +213,7 @@ export function RangeTrack({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
move(next, true);
|
move(next, true);
|
||||||
};
|
};
|
||||||
return (
|
return [
|
||||||
<div
|
<div
|
||||||
key={h.id}
|
key={h.id}
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -204,30 +225,47 @@ export function RangeTrack({
|
|||||||
aria-valuetext={h.valueText}
|
aria-valuetext={h.valueText}
|
||||||
data-handle={h.id}
|
data-handle={h.id}
|
||||||
// The ring sits inside the hit area: the track clips its overflow.
|
// 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"
|
className={clsx(
|
||||||
|
"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",
|
||||||
|
handleRingClassName,
|
||||||
|
)}
|
||||||
style={{ left: pct(f) }}
|
style={{ left: pct(f) }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (busy) return;
|
||||||
capture(e);
|
capture(e);
|
||||||
setDragging(h.id);
|
lastRef.current = h.time;
|
||||||
|
setDragging({ id: h.id, pointer: e.pointerId });
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
if (dragging !== h.id) return;
|
if (!mine(e)) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragging(null);
|
setDragging(null);
|
||||||
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => setDragging(null)}
|
onPointerCancel={(e) => {
|
||||||
|
if (!mine(e)) return;
|
||||||
|
setDragging(null);
|
||||||
|
move(lastRef.current, true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
||||||
|
</div>,
|
||||||
|
h.adornment != null && (
|
||||||
|
<div
|
||||||
|
key={`${h.id}-adornment`}
|
||||||
|
className="pointer-events-none absolute inset-y-0 z-base w-6 -translate-x-1/2"
|
||||||
|
style={{ left: pct(f) }}
|
||||||
|
>
|
||||||
{h.adornment}
|
{h.adornment}
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
|
];
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, type ComponentProps } from "react";
|
||||||
|
import { formatTimecode, parseTimecode } from "../../lib/timecode";
|
||||||
|
import { Input } from "./Input";
|
||||||
|
|
||||||
|
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
|
||||||
|
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
|
||||||
|
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
|
||||||
|
// stays, marked invalid, for fixing rather than vanishing or seeming taken. One element throughout,
|
||||||
|
// so Enter leaves the focus in the field (a field re-created per value dropped it on <body>).
|
||||||
|
|
||||||
|
type Props = Omit<
|
||||||
|
ComponentProps<typeof Input>,
|
||||||
|
"value" | "defaultValue" | "onChange" | "invalid"
|
||||||
|
> & {
|
||||||
|
/** Seconds. */
|
||||||
|
value: number;
|
||||||
|
/** Show hours (pass `duration >= 3600`, so one item's fields agree). */
|
||||||
|
hours?: boolean;
|
||||||
|
/** A readable time was typed (not called when it equals `value`). */
|
||||||
|
onCommit: (seconds: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimecodeField({
|
||||||
|
value,
|
||||||
|
hours = false,
|
||||||
|
onCommit,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
const invalid = draft != null && parseTimecode(draft) == null;
|
||||||
|
const commit = () => {
|
||||||
|
if (draft == null) return;
|
||||||
|
const v = parseTimecode(draft);
|
||||||
|
if (v == null) return;
|
||||||
|
setDraft(null);
|
||||||
|
if (Math.abs(v - value) > 1e-3) onCommit(v);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...rest}
|
||||||
|
value={draft ?? formatTimecode(value, hours)}
|
||||||
|
invalid={invalid}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
commit();
|
||||||
|
onBlur?.(e);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -435,6 +435,8 @@ export function useAutoSkip(opts: {
|
|||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
/** Says a refused watched mark out loud; the translated string belongs to the component layer. */
|
/** Says a refused watched mark out loud; the translated string belongs to the component layer. */
|
||||||
reportWatchedError: (e: unknown) => void;
|
reportWatchedError: (e: unknown) => void;
|
||||||
|
/** No countdown while true (a clip being marked); the Skip button still skips. */
|
||||||
|
suspended?: boolean;
|
||||||
}): {
|
}): {
|
||||||
activeMarker: PlexMarker | undefined;
|
activeMarker: PlexMarker | undefined;
|
||||||
skipProgress: number | null;
|
skipProgress: number | null;
|
||||||
@@ -453,6 +455,7 @@ export function useAutoSkip(opts: {
|
|||||||
go,
|
go,
|
||||||
seekTo,
|
seekTo,
|
||||||
videoRef,
|
videoRef,
|
||||||
|
suspended = false,
|
||||||
} = opts;
|
} = opts;
|
||||||
const activeMarker: PlexMarker | undefined = markers?.find(
|
const activeMarker: PlexMarker | undefined = markers?.find(
|
||||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||||
@@ -513,7 +516,7 @@ export function useAutoSkip(opts: {
|
|||||||
if (!m) return setSkipProgress(null);
|
if (!m) return setSkipProgress(null);
|
||||||
const key = `${m.type}-${m.start_s}`;
|
const key = `${m.type}-${m.start_s}`;
|
||||||
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
|
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
|
||||||
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
|
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
|
||||||
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
setSkipProgress(0);
|
setSkipProgress(0);
|
||||||
@@ -541,6 +544,7 @@ export function useAutoSkip(opts: {
|
|||||||
autoSkipIntro,
|
autoSkipIntro,
|
||||||
autoSkipCredits,
|
autoSkipCredits,
|
||||||
autoSkipDelay,
|
autoSkipDelay,
|
||||||
|
suspended,
|
||||||
doSkip,
|
doSkip,
|
||||||
videoRef,
|
videoRef,
|
||||||
activeMarkerRef,
|
activeMarkerRef,
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ describe("parseTimecode", () => {
|
|||||||
expect(parseTimecode(" 90.5 ")).toBeCloseTo(90.5);
|
expect(parseTimecode(" 90.5 ")).toBeCloseTo(90.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes a decimal comma, in the seconds only", () => {
|
||||||
|
expect(parseTimecode("1:02,5")).toBeCloseTo(62.5);
|
||||||
|
expect(parseTimecode("12,4")).toBeCloseTo(12.4);
|
||||||
|
});
|
||||||
|
|
||||||
it("is null for what is not a timecode", () => {
|
it("is null for what is not a timecode", () => {
|
||||||
for (const v of ["", "abc", "1::2", "1:2:3:4", "-5", "1:-2"])
|
for (const v of ["", "abc", "1::2", "1:2:3:4", "-5", "1:-2", "1,5:00", "1:02,5,5"])
|
||||||
expect(parseTimecode(v)).toBeNull();
|
expect(parseTimecode(v)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export function formatTimecode(s: number, hours = false): string {
|
|||||||
return hours || h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
|
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. */
|
/** A typed timecode back to seconds: `h:mm:ss.d`, `m:ss.d` or bare seconds, its decimal a point or
|
||||||
|
* a comma (how a Hungarian hand writes one). Null when it is not one. */
|
||||||
export function parseTimecode(v: string): number | null {
|
export function parseTimecode(v: string): number | null {
|
||||||
const parts = v.trim().split(":");
|
const parts = v
|
||||||
|
.trim()
|
||||||
|
.replace(/,(\d*)$/, ".$1")
|
||||||
|
.split(":");
|
||||||
if (parts.length > 3 || parts.some((p) => p.trim() === "")) return null;
|
if (parts.length > 3 || parts.some((p) => p.trim() === "")) return null;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
|
|||||||
Reference in New Issue
Block a user