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,270 @@
|
||||
import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
|
||||
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
|
||||
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
|
||||
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
|
||||
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
|
||||
//
|
||||
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
|
||||
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
|
||||
// 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.
|
||||
//
|
||||
// Every move reports `final`: false while a pointer drags, true on release or a key. A player whose
|
||||
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
|
||||
|
||||
export interface RangeWindow {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface TrackHandle {
|
||||
id: string;
|
||||
time: number;
|
||||
/** The range the handle may move in (seconds); a move past it stops at the edge. */
|
||||
min: number;
|
||||
max: number;
|
||||
/** Accessible name ("In", "Cut 2"). */
|
||||
label: string;
|
||||
/** The time as read out (aria-valuetext), in the caller's own format. */
|
||||
valueText: string;
|
||||
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
|
||||
step: number;
|
||||
bigStep: number;
|
||||
onChange: (time: number, final: boolean) => void;
|
||||
/** The drawn bar's look (its colour); the hit area around it is the track's. */
|
||||
barClassName?: string;
|
||||
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
|
||||
adornment?: ReactNode;
|
||||
}
|
||||
|
||||
interface TrackRegion {
|
||||
key: string | number;
|
||||
start: number;
|
||||
end: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
title?: string;
|
||||
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The media's length; with no `view` the track shows all of it. */
|
||||
duration: number;
|
||||
view?: RangeWindow;
|
||||
playhead?: number | null;
|
||||
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
|
||||
onSeek?: (time: number, final: boolean) => void;
|
||||
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
|
||||
onHover?: (h: { x: number; time: number } | null) => void;
|
||||
handles?: readonly TrackHandle[];
|
||||
regions?: readonly TrackRegion[];
|
||||
background?: ReactNode;
|
||||
/** The track's box: its height, surface and edge. */
|
||||
className?: string;
|
||||
/** Accessible name of the whole track. */
|
||||
label: string;
|
||||
playheadClassName?: string;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/** The time under `clientX` on the track that holds `el`. */
|
||||
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
|
||||
const track = el.closest("[data-range-track]") ?? el;
|
||||
const r = track.getBoundingClientRect();
|
||||
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
|
||||
return win.from + frac * (win.to - win.from);
|
||||
}
|
||||
|
||||
export function RangeTrack({
|
||||
duration,
|
||||
view,
|
||||
playhead,
|
||||
onSeek,
|
||||
onHover,
|
||||
handles = [],
|
||||
regions = [],
|
||||
background,
|
||||
className,
|
||||
label,
|
||||
playheadClassName = "bg-white",
|
||||
}: Props) {
|
||||
const win: RangeWindow = view ?? { from: 0, to: duration };
|
||||
const span = Math.max(win.to - win.from, 1e-6);
|
||||
const frac = (t: number) => (t - win.from) / span;
|
||||
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
|
||||
// acted on between its own down and up.
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
|
||||
const capture = (e: PointerEvent<HTMLElement>) => {
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-range-track=""
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={clsx("relative touch-none select-none overflow-hidden", className)}
|
||||
onPointerDown={(e) => {
|
||||
if (!onSeek || e.button !== 0) return;
|
||||
capture(e);
|
||||
setScrubbing(true);
|
||||
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const time = timeAtPointer(e.currentTarget, e.clientX, win);
|
||||
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
|
||||
if (scrubbing) onSeek?.(time, false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!scrubbing) return;
|
||||
setScrubbing(false);
|
||||
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setScrubbing(false)}
|
||||
onPointerLeave={() => onHover?.(null)}
|
||||
>
|
||||
{background}
|
||||
{regions.map((r) => {
|
||||
const a = clamp(frac(r.start), 0, 1);
|
||||
const b = clamp(frac(r.end), 0, 1);
|
||||
if (b <= a && r.end > r.start) return null; // wholly outside the window
|
||||
return (
|
||||
<div
|
||||
key={r.key}
|
||||
className={clsx("absolute inset-y-0", r.className)}
|
||||
style={{ left: pct(a), width: pct(b - a), ...r.style }}
|
||||
title={r.title}
|
||||
onPointerDown={r.onPointerDown}
|
||||
>
|
||||
{r.children}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{playhead != null && playhead >= win.from && playhead <= win.to && (
|
||||
<div
|
||||
data-testid="range-playhead"
|
||||
className={clsx(
|
||||
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
|
||||
playheadClassName,
|
||||
)}
|
||||
style={{ left: pct(frac(playhead)) }}
|
||||
/>
|
||||
)}
|
||||
{handles.map((h) => {
|
||||
const f = frac(h.time);
|
||||
if (f < 0 || f > 1) return null;
|
||||
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const big = e.shiftKey ? h.bigStep : h.step;
|
||||
const to: Record<string, number> = {
|
||||
ArrowLeft: h.time - big,
|
||||
ArrowDown: h.time - big,
|
||||
ArrowRight: h.time + big,
|
||||
ArrowUp: h.time + big,
|
||||
PageDown: h.time - h.bigStep,
|
||||
PageUp: h.time + h.bigStep,
|
||||
Home: h.min,
|
||||
End: h.max,
|
||||
};
|
||||
const next = to[e.key];
|
||||
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
|
||||
// skips a handled key).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
move(next, true);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={h.id}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={h.label}
|
||||
aria-valuemin={Math.round(h.min * 10) / 10}
|
||||
aria-valuemax={Math.round(h.max * 10) / 10}
|
||||
aria-valuenow={Math.round(h.time * 10) / 10}
|
||||
aria-valuetext={h.valueText}
|
||||
data-handle={h.id}
|
||||
// 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"
|
||||
style={{ left: pct(f) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
capture(e);
|
||||
setDragging(h.id);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (dragging !== h.id) return;
|
||||
e.stopPropagation();
|
||||
setDragging(null);
|
||||
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
|
||||
}}
|
||||
onPointerCancel={() => setDragging(null)}
|
||||
>
|
||||
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
|
||||
{h.adornment}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
|
||||
|
||||
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
|
||||
* each labelled by `format`. */
|
||||
export function RulerTicks({
|
||||
view,
|
||||
format,
|
||||
max = 8,
|
||||
className,
|
||||
}: {
|
||||
view: RangeWindow;
|
||||
format: (t: number) => string;
|
||||
max?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const span = Math.max(view.to - view.from, 1e-6);
|
||||
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
|
||||
const ticks: number[] = [];
|
||||
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
|
||||
return (
|
||||
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
|
||||
{ticks.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="absolute bottom-0 h-2 border-l border-current"
|
||||
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
|
||||
>
|
||||
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
|
||||
{format(t)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user