import { useEffect, useEffectEvent, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import clsx from "clsx"; import { trackFrac, trackPct } from "./trackPosition"; // 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 — and when the // browser takes the pointer away mid-drag (a cancel: a system gesture on a tablet), true where it // stopped. A player whose seek is expensive (a transcode restarts) acts on the final one only; a cheap // one follows the drag. A drag is its pointer's: a second finger neither takes it over nor starts // another, so two fingers on the track never fight over one mark. 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; } interface TrackRegion { key: string | number; start: number; end: number; className?: string; style?: CSSProperties; title?: string; onPointerDown?: (e: PointerEvent) => 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 frac = (t: number) => trackFrac(t, win); const pct = trackPct; // 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<{ id: string; pointer: number; el: HTMLElement; } | null>(null); const [scrubbing, setScrubbing] = useState(null); // Where the drag last put its handle, for a cancel to report as final. const lastRef = useRef(0); const busy = dragging != null || scrubbing != null; // A handle can go while its pointer is still down: its mark moved out of the window under it (a // key marked the other end and carried this one along). Its own up / cancel then never come — the // capture went with the node — and the drag would hold the track `busy` for good (a finger never // comes back with the same pointer). So the pointer's release anywhere ends it, reported final // where the mark now is: the caller's final-only work (a seek, a frozen view let go) still runs. const endLostDrag = useEffectEvent((id: string) => { setDragging(null); const h = handles.find((x) => x.id === id); if (h) h.onChange(clamp(h.time, h.min, h.max), true); }); useEffect(() => { if (!dragging) return; const { id, pointer, el } = dragging; const end = (e: globalThis.PointerEvent) => { if (e.pointerId === pointer && !el.isConnected) endLostDrag(id); }; document.addEventListener("pointerup", end); document.addEventListener("pointercancel", end); return () => { document.removeEventListener("pointerup", end); document.removeEventListener("pointercancel", end); }; }, [dragging]); const capture = (e: PointerEvent) => { try { e.currentTarget.setPointerCapture(e.pointerId); } catch { // No capture (a synthetic event): the drag still follows while the pointer stays on the target. } }; return (
{ if (!onSeek || e.button !== 0 || busy) return; capture(e); setScrubbing(e.pointerId); 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 === e.pointerId) onSeek?.(time, false); }} onPointerUp={(e) => { if (scrubbing !== e.pointerId) return; setScrubbing(null); onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true); }} // A cancelled scrub seeks no further: where it was taken away is not a place anyone chose. onPointerCancel={(e) => { if (scrubbing === e.pointerId) setScrubbing(null); }} 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 (
{r.children}
); })} {playhead != null && playhead >= win.from && playhead <= win.to && (
)} {handles.map((h) => { const f = frac(h.time); if (f < 0 || f > 1) return null; const move = (time: number, final: boolean) => { const t = clamp(time, h.min, h.max); lastRef.current = t; h.onChange(t, final); }; const mine = (e: PointerEvent) => dragging?.id === h.id && dragging.pointer === e.pointerId; const onKeyDown = (e: KeyboardEvent) => { const big = e.shiftKey ? h.bigStep : h.step; const to: Record = { 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 (
{ if (e.button !== 0) return; e.stopPropagation(); if (busy) return; capture(e); lastRef.current = h.time; setDragging({ id: h.id, pointer: e.pointerId, el: e.currentTarget }); }} onPointerMove={(e) => { if (mine(e)) move(timeAtPointer(e.currentTarget, e.clientX, win), false); }} onPointerUp={(e) => { if (!mine(e)) return; e.stopPropagation(); setDragging(null); move(timeAtPointer(e.currentTarget, e.clientX, win), true); }} onPointerCancel={(e) => { if (!mine(e)) return; setDragging(null); move(lastRef.current, true); }} >
); })}
); } 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 ( ); }