- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role alert) in its own flow under the fields' row: it no longer lies over the crop row taking its clicks, runs out of the editor's card, or joins the field's <label> name. - The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the cut's grab area, where a press drags it; RangeTrack's adornment slot is gone. - media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every bg-media-chrome surface; a contract test holds each such class string to media-focus or media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme. - An upload's quota headline says the converted file does not fit, not "Storage full". - DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all three colours on the chrome.
469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
|
import { api, type ClipRequest } from "../lib/api";
|
|
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
|
import { invalidateDownloads } from "../lib/downloads";
|
|
import { notify } from "../lib/notifications";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { navigateTo } from "../lib/nav";
|
|
import { log } from "../lib/log";
|
|
import { formatTimecode } from "../lib/timecode";
|
|
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
|
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
|
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
|
import { CropFrame } from "./ui/CropFrame";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Input } from "./ui/Input";
|
|
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
|
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
|
|
|
|
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
|
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
|
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
|
|
//
|
|
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
|
|
// around the selection where the handles are. The strip's window follows the selection when it
|
|
// settles, not while a handle is being dragged — a window that moved under the pointer would run
|
|
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
|
|
// precision, and the fields and their tenth-of-a-second steps give the rest.
|
|
//
|
|
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
|
|
// mark the same range by the same rules (lib/clipRange).
|
|
//
|
|
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
|
|
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
|
|
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
|
|
// before it closes the panel.
|
|
|
|
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
|
|
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
|
|
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
|
|
"16:9": 16 / 9,
|
|
"9:16": 9 / 16,
|
|
"1:1": 1,
|
|
};
|
|
|
|
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
|
|
export interface ClipCrop {
|
|
rect: Frac;
|
|
shape: ClipShape;
|
|
}
|
|
|
|
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
|
|
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
|
|
const lockOf = (crop: ClipCrop, picture: number) =>
|
|
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
|
|
|
|
export interface ClipSource {
|
|
source: ClipRequest["source"];
|
|
ref: string;
|
|
/** Plex: the audio track that is playing, which the clip carries. */
|
|
audioTrack?: number | null;
|
|
}
|
|
|
|
interface Props {
|
|
src: ClipSource;
|
|
clip: ClipRange;
|
|
onChange: (r: ClipRange) => void;
|
|
duration: number;
|
|
/** The player's position, live. */
|
|
current: number;
|
|
/** The shape (width / height) of the picture the crop frame is drawn on. */
|
|
picture: number;
|
|
crop: ClipCrop | null;
|
|
onCrop: (c: ClipCrop | null) => void;
|
|
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
|
|
onSeek: (time: number, final: boolean) => void;
|
|
/** Play from `time` (the loop's restart, and "Play selection"). */
|
|
onPlayFrom: (time: number) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const NUDGE_S = 0.1;
|
|
// 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, and the item counts as finished — YouTube marks
|
|
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
|
|
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
|
|
// the item unfinished.)
|
|
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
|
|
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
|
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
|
|
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
|
|
// is: the theme accent's ring was under 3:1 on the chrome.
|
|
const PLAYHEAD = "bg-media-mark";
|
|
|
|
export default function ClipPanel({
|
|
src,
|
|
clip,
|
|
onChange,
|
|
duration,
|
|
current,
|
|
picture,
|
|
crop,
|
|
onCrop,
|
|
onSeek,
|
|
onPlayFrom,
|
|
onClose,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const rootRef = useRef<HTMLFormElement>(null);
|
|
const [name, setName] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [looping, setLooping] = useState(false);
|
|
// The strip's window while a handle drags (null at rest: it follows the selection).
|
|
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
|
|
const hours = duration >= 3600;
|
|
const tc = (s: number) => formatTimecode(s, hours);
|
|
const view = frozen ?? zoomWindow(clip, duration);
|
|
const bounds = handleBounds(clip, duration);
|
|
const length = clip.end - clip.start;
|
|
|
|
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
|
|
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
|
|
// scrolling there: the player's card is still fitting itself around the new panel at this point,
|
|
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
|
|
useEffect(() => {
|
|
rootRef.current
|
|
?.querySelector<HTMLElement>('[data-handle="in"]')
|
|
?.focus({ preventScroll: true });
|
|
}, []);
|
|
|
|
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
|
|
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
|
|
const cropButtonRef = useRef<HTMLButtonElement>(null);
|
|
const cropOn = crop != null;
|
|
useEffect(() => {
|
|
if (cropOn) return;
|
|
const lost = document.activeElement == null || document.activeElement === document.body;
|
|
if (lost) cropButtonRef.current?.focus();
|
|
}, [cropOn]);
|
|
|
|
// "Play selection" loops: past Out, back to In.
|
|
const restart = useEffectEvent(() => onPlayFrom(clip.start));
|
|
useEffect(() => {
|
|
if (looping && current >= clip.end) restart();
|
|
}, [looping, current, clip.end]);
|
|
|
|
const seek = (time: number, final: boolean) =>
|
|
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
|
|
const moveIn = (time: number, final: boolean) => {
|
|
setFrozen(final ? null : (frozen ?? view));
|
|
const next = markIn(clip, time, duration);
|
|
onChange(next);
|
|
seek(next.start, final);
|
|
};
|
|
const moveOut = (time: number, final: boolean) => {
|
|
setFrozen(final ? null : (frozen ?? view));
|
|
const next = markOut(clip, time, duration);
|
|
onChange(next);
|
|
seek(next.end, final);
|
|
};
|
|
|
|
const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true));
|
|
const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true));
|
|
const timeErrorId = useId();
|
|
const body: ClipRequest = {
|
|
source: src.source,
|
|
ref: src.ref,
|
|
start_s: Math.round(clip.start * 1000) / 1000,
|
|
end_s: Math.round(clip.end * 1000) / 1000,
|
|
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
|
...(crop
|
|
? {
|
|
crop: {
|
|
x: round4(crop.rect.x),
|
|
y: round4(crop.rect.y),
|
|
w: round4(crop.rect.w),
|
|
h: round4(crop.rect.h),
|
|
aspect: round4(picture),
|
|
},
|
|
}
|
|
: {}),
|
|
...(name.trim() ? { display_name: name.trim() } : {}),
|
|
};
|
|
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
await api.enqueueClip(body);
|
|
invalidateDownloads(qc);
|
|
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
|
notify({
|
|
level: "success",
|
|
message: t("clip.queued"),
|
|
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
|
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
|
notifyActionError(err, t("clip.failed"));
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form
|
|
ref={rootRef}
|
|
onSubmit={submit}
|
|
aria-label={t("clip.title")}
|
|
data-testid="clip-panel"
|
|
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
|
|
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex shrink-0 items-center gap-1.5 font-medium">
|
|
<Scissors className="h-4 w-4" aria-hidden="true" />
|
|
{t("clip.title")}
|
|
</span>
|
|
<RangeTrack
|
|
label={t("clip.overview")}
|
|
duration={duration}
|
|
playhead={current}
|
|
playheadClassName={PLAYHEAD}
|
|
onSeek={(time, final) => final && seek(time, true)}
|
|
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
|
|
regions={[
|
|
{
|
|
key: "window",
|
|
start: view.from,
|
|
end: view.to,
|
|
className: "border-x border-white/50",
|
|
},
|
|
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
|
|
]}
|
|
/>
|
|
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
|
|
<RangeTrack
|
|
label={t("clip.strip")}
|
|
duration={duration}
|
|
view={view}
|
|
playhead={current}
|
|
playheadClassName={PLAYHEAD}
|
|
onSeek={seek}
|
|
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
|
background={
|
|
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
|
}
|
|
regions={[
|
|
{
|
|
key: "sel",
|
|
start: clip.start,
|
|
end: clip.end,
|
|
className: "bg-accent/25 border-y-2 border-accent",
|
|
},
|
|
]}
|
|
handles={[
|
|
{
|
|
id: "in",
|
|
label: t("clip.in"),
|
|
time: clip.start,
|
|
...bounds.in,
|
|
valueText: tc(clip.start),
|
|
step: NUDGE_S,
|
|
bigStep: 1,
|
|
onChange: moveIn,
|
|
barClassName: "bg-white",
|
|
},
|
|
{
|
|
id: "out",
|
|
label: t("clip.out"),
|
|
time: clip.end,
|
|
...bounds.out,
|
|
valueText: tc(clip.end),
|
|
step: NUDGE_S,
|
|
bigStep: 1,
|
|
onChange: moveOut,
|
|
barClassName: "bg-white",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
<MarkField
|
|
label={t("clip.in")}
|
|
value={clip.start}
|
|
field={inField}
|
|
errorId={timeErrorId}
|
|
onSet={(v) => moveIn(v, true)}
|
|
nudge={NUDGE_S}
|
|
hereLabel={t("clip.inHere")}
|
|
onHere={() => moveIn(current, true)}
|
|
/>
|
|
<MarkField
|
|
label={t("clip.out")}
|
|
value={clip.end}
|
|
field={outField}
|
|
errorId={timeErrorId}
|
|
onSet={(v) => moveOut(v, true)}
|
|
nudge={NUDGE_S}
|
|
hereLabel={t("clip.outHere")}
|
|
onHere={() => moveOut(current, true)}
|
|
/>
|
|
<span className="tabular-nums text-muted" aria-live="polite">
|
|
{t("clip.length", { length: formatTimecode(length) })}
|
|
</span>
|
|
<Button
|
|
variant="overlay"
|
|
size="sm"
|
|
pressed={looping}
|
|
icon={<Repeat />}
|
|
onClick={() => {
|
|
if (!looping) onPlayFrom(clip.start);
|
|
setLooping(!looping);
|
|
}}
|
|
>
|
|
{t("clip.loop")}
|
|
</Button>
|
|
</div>
|
|
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
ref={cropButtonRef}
|
|
variant="overlay"
|
|
size="sm"
|
|
pressed={crop != null}
|
|
icon={<Crop />}
|
|
onClick={() => onCrop(crop ? null : CROP_START)}
|
|
>
|
|
{t("clip.crop")}
|
|
</Button>
|
|
{crop && (
|
|
<>
|
|
<SegmentedToggle
|
|
ariaLabel={t("clip.shape")}
|
|
value={crop.shape}
|
|
onChange={(shape) => {
|
|
const next = { ...crop, shape };
|
|
const lock = lockOf(next, picture);
|
|
onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) });
|
|
}}
|
|
options={[
|
|
{ value: "free", label: t("clip.shapeFree") },
|
|
{ value: "16:9", label: "16:9" },
|
|
{ value: "9:16", label: "9:16" },
|
|
{ value: "1:1", label: "1:1" },
|
|
]}
|
|
/>
|
|
<Button
|
|
variant="overlay"
|
|
size="sm"
|
|
icon={<Maximize />}
|
|
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
|
|
>
|
|
{t("clip.whole")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input
|
|
size="sm"
|
|
className="min-w-48 flex-1"
|
|
value={name}
|
|
maxLength={DISPLAY_NAME_MAX}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t("clip.namePlaceholder")}
|
|
aria-label={t("clip.name")}
|
|
/>
|
|
<Button variant="overlay" size="sm" onClick={onClose}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
|
|
{t("clip.cut")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
|
|
* and "to the playhead". */
|
|
function MarkField({
|
|
label,
|
|
value,
|
|
field,
|
|
errorId,
|
|
onSet,
|
|
nudge,
|
|
hereLabel,
|
|
onHere,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
field: TimecodeDraft;
|
|
errorId: string;
|
|
onSet: (v: number) => void;
|
|
nudge: number;
|
|
hereLabel: string;
|
|
onHere: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
|
<span className="mr-1 text-muted">{label}</span>
|
|
<IconButton
|
|
variant="overlay"
|
|
size="sm"
|
|
onClick={() => onSet(value - nudge)}
|
|
label={t("clip.earlier", { label })}
|
|
>
|
|
<Minus />
|
|
</IconButton>
|
|
<TimecodeField
|
|
size="sm"
|
|
className="w-24 text-center tabular-nums"
|
|
field={field}
|
|
errorId={errorId}
|
|
aria-label={label}
|
|
/>
|
|
<IconButton
|
|
variant="overlay"
|
|
size="sm"
|
|
onClick={() => onSet(value + nudge)}
|
|
label={t("clip.later", { label })}
|
|
>
|
|
<Plus />
|
|
</IconButton>
|
|
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
|
|
<Crosshair />
|
|
</IconButton>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */
|
|
export function ClipCropFrame({
|
|
picture,
|
|
crop,
|
|
onCrop,
|
|
}: {
|
|
picture: number;
|
|
crop: ClipCrop;
|
|
onCrop: (c: ClipCrop) => void;
|
|
}) {
|
|
return (
|
|
<CropFrame
|
|
aspect={picture}
|
|
rect={crop.rect}
|
|
ratio={lockOf(crop, picture)}
|
|
onChange={(rect) => onCrop({ ...crop, rect })}
|
|
className="z-base"
|
|
/>
|
|
);
|
|
}
|