A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
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 { showInTray } from "../../lib/downloadQueue";
|
|
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 "../../components/ui/RangeTrack";
|
|
import { CropFrame } from "../../components/ui/CropFrame";
|
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { IconButton } from "../../components/ui/IconButton";
|
|
import { Input } from "../../components/ui/Input";
|
|
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
|
|
import { useTimecodeField, type TimecodeDraft } from "../../components/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 {
|
|
const job = 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: () => showInTray(job.id) },
|
|
});
|
|
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"
|
|
/>
|
|
);
|
|
}
|