/* Multi-track Timeline — Shots / Dialogue / Camera / FX, playhead, zoom. */ import { useRef, useState } from "react"; import { Icon } from "./Icon"; import { fmt } from "../state/util"; import type { EpisodeDocument } from "../state/types"; interface ClipModel { id: string; start: number; dur: number; label: string; accent: string; } interface TrackModel { name: string; icon: string; dot: string; rows: ClipModel[]; } function Clip({ clip, DUR, sel, onClick, trackRef, zoom, onRetime }: { clip: ClipModel; DUR: number; sel: boolean; onClick: (e: React.MouseEvent) => void; trackRef: React.RefObject; zoom: number; onRetime?: (clipId: string, newDuration: number) => void; }) { const left = (clip.start / DUR) * 100; const width = (clip.dur / DUR) * 100; const handleDown = (e: React.MouseEvent) => { e.stopPropagation(); if (!onRetime || !trackRef.current) return; const initialDur = clip.dur; const startX = e.clientX; const trackWidth = trackRef.current.getBoundingClientRect().width; const pxPerSecond = (trackWidth * zoom) / DUR; const onMove = (ev: MouseEvent) => { const delta = ev.clientX - startX; const newDur = Math.max(1, initialDur + delta / pxPerSecond); onRetime(clip.id, newDur); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; return (
{clip.label} {onRetime && (
)}
); } export interface TimelineProps { data: EpisodeDocument; time: number; onScrub: (t: number) => void; selShot: string | null; onSelShot: (id: string) => void; onRetime?: (clipId: string, newDuration: number) => void; } export function Timeline({ data, time, onScrub, selShot, onSelShot, onRetime }: TimelineProps) { const DUR = data.DUR; const [zoom, setZoom] = useState(1); const ref = useRef(null); const ticks: number[] = []; for (let t = 0; t <= DUR; t += 10) ticks.push(t); const scrubAt = (clientX: number) => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (clientX - r.left + el.scrollLeft) / (r.width * zoom))); onScrub(pct * DUR); }; const onDown = (e: React.MouseEvent) => { scrubAt(e.clientX); const mv = (ev: MouseEvent) => scrubAt(ev.clientX); const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); }; const charColor = (who: string) => data.cast.find((c) => c.id === who)?.color || "#6b6bff"; const tracks: TrackModel[] = [ { name: "Shots", icon: "film", dot: "#6b6bff", rows: data.shots.map((s) => ({ id: s.id, start: s.start, dur: s.dur, label: s.name, accent: s.color })) }, { name: "Dialogue", icon: "mic", dot: "#ff8a5b", rows: data.beats.map((b) => { const name = data.cast.find((c) => c.id === b.who)?.name || "?"; const label = name + " · " + (b.text.length > 22 ? b.text.slice(0, 22) + "…" : b.text); return { id: b.id, start: b.start, dur: b.dur, label, accent: charColor(b.who) }; }) }, { name: "Gestures", icon: "sparkles", dot: "#9a7bff", rows: data.gestures.map((g) => ({ id: g.id, start: g.start, dur: g.dur, label: g.text, accent: g.color })) }, { name: "Camera", icon: "camera", dot: "#2dd4a7", rows: data.camera.map((c) => ({ id: c.id, start: c.start, dur: c.dur, label: c.text, accent: c.color })) }, { name: "FX", icon: "zap", dot: "#ff6f4d", rows: data.fx.map((fxc) => ({ id: fxc.id, start: fxc.start, dur: fxc.dur, label: fxc.text, accent: fxc.color })) } ]; return (
Timeline {fmt(time)}
{zoom.toFixed(1) + "×"}
time
{tracks.map((t) => (
{t.name}
))}
{ticks.map((t) => (
{fmt(t)}
))}
{tracks.map((t) => (
{t.rows.map((r) => ( { e.stopPropagation(); onScrub(r.start); if (t.name === "Shots") onSelShot(r.id); }} trackRef={ref} zoom={zoom} onRetime={t.name === "Shots" ? onRetime : undefined} /> ))}
))}
); }