feat(player): clip mode - mark a range of what is playing and cut it into a download
Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so the watched video is the preview: an overview of the whole item and a zoomed strip around the selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads link plus the usual Ready notification. - ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The Downloads editor's cuts moved onto it, so they are keyboard-steppable now. - lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing timecode the editor's private tc/parseTc became. - keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt. - The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches at any depth. - media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState, type SubmitEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Crosshair, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||
import { api, type ClipRequest } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { log } from "../lib/log";
|
||||
import { formatTimecode, parseTimecode } from "../lib/timecode";
|
||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
||||
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
|
||||
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
||||
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
||||
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
|
||||
//
|
||||
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
|
||||
// around the selection where the handles are. The strip's window follows the selection when it
|
||||
// settles, not while a handle is being dragged — a window that moved under the pointer would run
|
||||
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
|
||||
// precision, and the fields and their tenth-of-a-second steps give the rest.
|
||||
//
|
||||
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
|
||||
// mark the same range by the same rules (lib/clipRange).
|
||||
|
||||
export interface ClipSource {
|
||||
source: ClipRequest["source"];
|
||||
ref: string;
|
||||
/** Plex: the audio track that is playing, which the clip carries. */
|
||||
audioTrack?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src: ClipSource;
|
||||
clip: ClipRange;
|
||||
onChange: (r: ClipRange) => void;
|
||||
duration: number;
|
||||
/** The player's position, live. */
|
||||
current: number;
|
||||
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
|
||||
onSeek: (time: number, final: boolean) => void;
|
||||
/** Play from `time` (the loop's restart, and "Play selection"). */
|
||||
onPlayFrom: (time: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NUDGE_S = 0.1;
|
||||
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
|
||||
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
|
||||
const PLAYHEAD = "bg-warn";
|
||||
|
||||
export default function ClipPanel({
|
||||
src,
|
||||
clip,
|
||||
onChange,
|
||||
duration,
|
||||
current,
|
||||
onSeek,
|
||||
onPlayFrom,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rootRef = useRef<HTMLFormElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [looping, setLooping] = useState(false);
|
||||
// The strip's window while a handle drags (null at rest: it follows the selection).
|
||||
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
|
||||
const hours = duration >= 3600;
|
||||
const tc = (s: number) => formatTimecode(s, hours);
|
||||
const view = frozen ?? zoomWindow(clip, duration);
|
||||
const bounds = handleBounds(clip, duration);
|
||||
const length = clip.end - clip.start;
|
||||
|
||||
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
|
||||
// opened it is gone (its row is this panel now), and the arrows then move the mark at once.
|
||||
useEffect(() => {
|
||||
rootRef.current?.querySelector<HTMLElement>('[data-handle="in"]')?.focus();
|
||||
}, []);
|
||||
|
||||
// "Play selection" loops: past Out, back to In.
|
||||
const restart = useEffectEvent(() => onPlayFrom(clip.start));
|
||||
useEffect(() => {
|
||||
if (looping && current >= clip.end) restart();
|
||||
}, [looping, current, clip.end]);
|
||||
|
||||
const moveIn = (time: number, final: boolean) => {
|
||||
setFrozen(final ? null : (frozen ?? view));
|
||||
onChange(markIn(clip, time, duration));
|
||||
onSeek(time, final);
|
||||
};
|
||||
const moveOut = (time: number, final: boolean) => {
|
||||
setFrozen(final ? null : (frozen ?? view));
|
||||
onChange(markOut(clip, time, duration));
|
||||
onSeek(time, final);
|
||||
};
|
||||
|
||||
const body: ClipRequest = {
|
||||
source: src.source,
|
||||
ref: src.ref,
|
||||
start_s: Math.round(clip.start * 1000) / 1000,
|
||||
end_s: Math.round(clip.end * 1000) / 1000,
|
||||
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
|
||||
...(name.trim() ? { display_name: name.trim() } : {}),
|
||||
};
|
||||
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.enqueueClip(body);
|
||||
invalidateDownloads(qc);
|
||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("clip.queued"),
|
||||
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
||||
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
||||
notifyActionError(err, t("clip.failed"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={rootRef}
|
||||
onSubmit={submit}
|
||||
aria-label={t("clip.title")}
|
||||
data-testid="clip-panel"
|
||||
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
|
||||
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex shrink-0 items-center gap-1.5 font-medium">
|
||||
<Scissors className="h-4 w-4" aria-hidden="true" />
|
||||
{t("clip.title")}
|
||||
</span>
|
||||
<RangeTrack
|
||||
label={t("clip.overview")}
|
||||
duration={duration}
|
||||
playhead={current}
|
||||
playheadClassName={PLAYHEAD}
|
||||
onSeek={(time, final) => final && onSeek(time, true)}
|
||||
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
|
||||
regions={[
|
||||
{
|
||||
key: "window",
|
||||
start: view.from,
|
||||
end: view.to,
|
||||
className: "border-x border-white/50",
|
||||
},
|
||||
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
|
||||
]}
|
||||
/>
|
||||
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<RangeTrack
|
||||
label={t("clip.strip")}
|
||||
duration={duration}
|
||||
view={view}
|
||||
playhead={current}
|
||||
playheadClassName={PLAYHEAD}
|
||||
onSeek={onSeek}
|
||||
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
|
||||
background={
|
||||
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
|
||||
}
|
||||
regions={[
|
||||
{
|
||||
key: "sel",
|
||||
start: clip.start,
|
||||
end: clip.end,
|
||||
className: "bg-accent/25 border-y-2 border-accent",
|
||||
},
|
||||
]}
|
||||
handles={[
|
||||
{
|
||||
id: "in",
|
||||
label: t("clip.in"),
|
||||
time: clip.start,
|
||||
...bounds.in,
|
||||
valueText: tc(clip.start),
|
||||
step: NUDGE_S,
|
||||
bigStep: 1,
|
||||
onChange: moveIn,
|
||||
barClassName: "bg-white",
|
||||
},
|
||||
{
|
||||
id: "out",
|
||||
label: t("clip.out"),
|
||||
time: clip.end,
|
||||
...bounds.out,
|
||||
valueText: tc(clip.end),
|
||||
step: NUDGE_S,
|
||||
bigStep: 1,
|
||||
onChange: moveOut,
|
||||
barClassName: "bg-white",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<MarkField
|
||||
label={t("clip.in")}
|
||||
value={clip.start}
|
||||
text={tc(clip.start)}
|
||||
onSet={(v) => moveIn(v, true)}
|
||||
nudge={NUDGE_S}
|
||||
hereLabel={t("clip.inHere")}
|
||||
onHere={() => moveIn(current, true)}
|
||||
/>
|
||||
<MarkField
|
||||
label={t("clip.out")}
|
||||
value={clip.end}
|
||||
text={tc(clip.end)}
|
||||
onSet={(v) => moveOut(v, true)}
|
||||
nudge={NUDGE_S}
|
||||
hereLabel={t("clip.outHere")}
|
||||
onHere={() => moveOut(current, true)}
|
||||
/>
|
||||
<span className="tabular-nums text-muted" aria-live="polite">
|
||||
{t("clip.length", { length: formatTimecode(length) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={looping}
|
||||
icon={<Repeat />}
|
||||
onClick={() => {
|
||||
if (!looping) onPlayFrom(clip.start);
|
||||
setLooping(!looping);
|
||||
}}
|
||||
>
|
||||
{t("clip.loop")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
size="sm"
|
||||
className="min-w-48 flex-1"
|
||||
value={name}
|
||||
maxLength={255}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("clip.namePlaceholder")}
|
||||
aria-label={t("clip.name")}
|
||||
/>
|
||||
<Button variant="overlay" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
|
||||
{t("clip.cut")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
|
||||
* and "to the playhead". */
|
||||
function MarkField({
|
||||
label,
|
||||
value,
|
||||
text,
|
||||
onSet,
|
||||
nudge,
|
||||
hereLabel,
|
||||
onHere,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
text: string;
|
||||
onSet: (v: number) => void;
|
||||
nudge: number;
|
||||
hereLabel: string;
|
||||
onHere: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const commit = (raw: string) => {
|
||||
const v = parseTimecode(raw);
|
||||
if (v != null && Math.abs(v - value) > 1e-3) onSet(v);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
||||
<span className="mr-1 text-muted">{label}</span>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={() => onSet(value - nudge)}
|
||||
label={t("clip.earlier", { label })}
|
||||
>
|
||||
<Minus />
|
||||
</IconButton>
|
||||
<Input
|
||||
// Re-created when the mark moves elsewhere, so the field shows it; a typed draft lives in the
|
||||
// field until Enter or blur.
|
||||
key={text}
|
||||
size="sm"
|
||||
className="w-24 text-center tabular-nums"
|
||||
defaultValue={text}
|
||||
aria-label={label}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
commit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={() => onSet(value + nudge)}
|
||||
label={t("clip.later", { label })}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
|
||||
<Crosshair />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user