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, 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(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(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('[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 , where the player's dialog no longer holds it. const cropButtonRef = useRef(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) => { 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 (
e.stopPropagation()} >
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" }, ]} />
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", }, ]} />
moveIn(v, true)} nudge={NUDGE_S} hereLabel={t("clip.inHere")} onHere={() => moveIn(current, true)} /> moveOut(v, true)} nudge={NUDGE_S} hereLabel={t("clip.outHere")} onHere={() => moveOut(current, true)} /> {t("clip.length", { length: formatTimecode(length) })}
{(inField.invalid || outField.invalid) && }
{crop && ( <> { 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" }, ]} /> )}
setName(e.target.value)} placeholder={t("clip.namePlaceholder")} aria-label={t("clip.name")} />
); } /** 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 (
{label} onSet(value - nudge)} label={t("clip.earlier", { label })} > onSet(value + nudge)} label={t("clip.later", { label })} >
); } /** 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 ( onCrop({ ...crop, rect })} className="z-base" /> ); }