import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import {
Plus, Scissors, Magnet, Mic, Play, Pause, ZoomIn, ZoomOut, Maximize2, Eye, EyeOff,
Volume2, VolumeX, Trash2, ArrowRightLeft, Lock, Unlock, ChevronUp, ChevronDown, Copy,
Video, AudioLines, Square, FoldHorizontal, MoreHorizontal, Music2, X, Link2, Unlink, Monitor, Combine,
} from "lucide-react";
import { useStore, selectStats, type MotionItem, type Track } from "../store";
import { ScreenRecorder } from "./ScreenRecorder";
import { sfxForScene } from "../remotion/mg/catalog.mjs";
import { CaptionMenu } from "./CaptionMenu";
import { AutoPreview } from "./TemplateGallery";
import { TRANSITIONS, DEFAULT_TRANSITION_MS, type ClipTransition } from "../lib/transitions";
import { fmtTime } from "../lib/time";
const LABEL_W = 150;
const tickInterval = (pxps: number) => [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300].find((n) => n * pxps >= 64) || 300;
function IconBtn({ children, title, onClick, active, disabled }: { children: React.ReactNode; title?: string; onClick?: () => void; active?: boolean; disabled?: boolean }) {
return ;
}
// Subscribes to playheadMs alone so playback doesn't re-render the whole timeline.
function Playhead({ pxps, onScrub }: { pxps: number; onScrub: (e: React.PointerEvent) => void }) {
const playheadMs = useStore((s) => s.playheadMs);
return (
);
}
// the magnet guide line: a bright vertical line spanning every lane at the locked time while dragging —
// the "you snapped" affordance every pro NLE shows. Colour-codes the anchor (playhead vs clip-edge).
function SnapGuide({ snap, pxps }: { snap: { ms: number; type: "playhead" | "edge" | "bound" }; pxps: number }) {
const color = snap.type === "playhead" ? "var(--tl-playhead)" : snap.type === "bound" ? "#9CA3FF" : "#2DE3C0";
return (
);
}
function TimeReadout({ durationMs }: { durationMs: number }) {
const playheadMs = useStore((s) => s.playheadMs);
return {fmtTime(playheadMs)} / {fmtTime(durationMs)};
}
function TransportButton() {
const playing = useStore((s) => s.playing);
const toggleFn = useStore((s) => s.toggleFn);
return ;
}
const TRACK_COLOR = (t: Track) => (t.kind === "audio" ? "var(--color-tl-audio)" : "var(--color-tl-video)");
// scroll `el` horizontally when a drag's pointer is near its left/right edge → returns px actually
// scrolled (0 if none). Lets clip-drags and box-select keep going past the visible edge with the cursor.
function edgeScrollStep(el: HTMLElement, clientX: number): number {
const EDGE = 64, MAX = 24;
const r = el.getBoundingClientRect();
let dx = 0;
if (clientX > r.right - EDGE) dx = Math.min(MAX, ((clientX - (r.right - EDGE)) / EDGE) * MAX);
else if (clientX < r.left + EDGE) dx = -Math.min(MAX, (((r.left + EDGE) - clientX) / EDGE) * MAX);
if (!dx) return 0;
const before = el.scrollLeft;
el.scrollLeft = Math.max(0, before + dx);
return el.scrollLeft - before;
}
function requestProgramFullscreen() {
const el = document.getElementById("program-monitor");
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.().catch(() => {});
}
export function Timeline() {
const edl = useStore((s) => s.edl);
const motion = useStore((s) => s.motion);
const videos = useStore((s) => s.videos);
const tracks = useStore((s) => s.tracks);
const activeTrackId = useStore((s) => s.activeTrackId);
const setMusic = useStore((s) => s.setMusic);
const sfxStyle = useStore((s) => s.sfxStyle);
const removeMotion = useStore((s) => s.removeMotion);
const updateMotion = useStore((s) => s.updateMotion);
const selectMotion = useStore((s) => s.selectMotion);
const selectVideo = useStore((s) => s.selectVideo);
const selectedVideoId = useStore((s) => s.selectedVideoId);
const toggleSelect = useStore((s) => s.toggleSelect);
const selectRange = useStore((s) => s.selectRange);
const selectedIds = useStore((s) => s.selectedIds);
const removeSelected = useStore((s) => s.removeSelected);
const clearSelection = useStore((s) => s.clearSelection);
const setSelection = useStore((s) => s.setSelection);
const groupSelectedAction = useStore((s) => s.groupSelected);
const ungroupSelectedAction = useStore((s) => s.ungroupSelected);
const combineSelectedAction = useStore((s) => s.combineSelected);
// how many selected clips are combinable media (video/image, not audio) — gates the Combine button
const selectedMediaCount = useStore((s) => s.selectedIds.reduce((n, id) => { const v = s.videos.find((x) => x.id === id); return v && v.kind !== "audio" ? n + 1 : n; }, 0));
const setStarts = useStore((s) => s.setStarts);
// ── grouping helpers ──
const clipById = (id: string) => { const st = useStore.getState(); return st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); };
const groupMemberIds = (g: string) => { const st = useStore.getState(); return [...st.motion.filter((m) => m.group === g), ...st.videos.filter((v) => v.group === g)].map((c) => c.id); };
// a stable, vivid colour per group id so a group's members read as visually linked
const groupColor = (g: string) => { let h = 0; for (let i = 0; i < g.length; i++) h = (h * 31 + g.charCodeAt(i)) % 360; return `hsl(${h} 88% 64%)`; };
// which clips should move together when dragging `id`: its whole group, else the multi-selection, else just it
const resolveMoveSet = (id: string): string[] => {
const st = useStore.getState();
const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id);
if (c?.group) return groupMemberIds(c.group);
if (st.selectedIds.includes(id) && st.selectedIds.length > 1) return st.selectedIds.slice();
return [id];
};
const startsOf = (ids: string[]): Record => { const st = useStore.getState(); const out: Record = {}; for (const id of ids) { const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); if (c) out[id] = c.startMs; } return out; };
const multiMove = { resolve: resolveMoveSet, startsOf, apply: setStarts, select: (ids: string[]) => setSelection(ids, false) };
const selectedGrouped = selectedIds.some((id) => !!clipById(id)?.group);
// plain-click selection that EXPANDS to the whole group when the clicked clip is grouped
const onSceneSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
if (mods.meta) return toggleSelect(id);
if (mods.shift) return selectRange(id);
const s = useStore.getState();
const c = s.motion.find((m) => m.id === id);
if (c?.group) return setSelection(groupMemberIds(c.group), false);
// in multi-select mode (≥1 selected, nothing open in the inspector) a plain click toggles; else open
if (s.inspectId == null && s.selectedIds.length > 0) toggleSelect(id);
else selectMotion(id);
};
const onVideoSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
if (mods.meta) return toggleSelect(id);
const s = useStore.getState();
const c = s.videos.find((v) => v.id === id);
if (c?.group) return setSelection(groupMemberIds(c.group), false);
selectVideo(id);
};
// ⌘/Ctrl+G groups the selected clips, ⌘/Ctrl+Shift+G ungroups
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tg = e.target as HTMLElement | null;
if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.isContentEditable)) return;
if ((e.metaKey || e.ctrlKey) && (e.key === "g" || e.key === "G")) {
e.preventDefault();
if (e.shiftKey) ungroupSelectedAction(); else groupSelectedAction();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [groupSelectedAction, ungroupSelectedAction]);
const removeVideo = useStore((s) => s.removeVideo);
const updateVideo = useStore((s) => s.updateVideo);
const duplicateVideo = useStore((s) => s.duplicateVideo);
const duplicateMotion = useStore((s) => s.duplicateMotion);
const setTransition = useStore((s) => s.setTransition);
const pushHistory = useStore((s) => s.pushHistory);
const setPlayhead = useStore((s) => s.setPlayhead);
const splitAtPlayhead = useStore((s) => s.splitAtPlayhead);
const closeGaps = useStore((s) => s.closeGaps);
const splitClip = useStore((s) => s.splitClip);
const stats = useStore(useShallow(selectStats));
const [pxps, setPxps] = useState(16);
const [snap, setSnap] = useState(true);
// the active magnet guide line while dragging/trimming (the time + what it locked onto) — drawn over the lanes
const [snapLine, setSnapLine] = useState<{ ms: number; type: "playhead" | "edge" | "bound" } | null>(null);
const snapRef = useRef<{ ms: number; type: string } | null>(null);
const setLine = (next: { ms: number; type: "playhead" | "edge" | "bound" } | null) => {
const a = snapRef.current, same = (!a && !next) || (!!a && !!next && a.ms === next.ms && a.type === next.type);
if (!same) { snapRef.current = next; setSnapLine(next); }
};
const [addMenu, setAddMenu] = useState(false);
const [recording, setRecording] = useState(false);
const [screenRec, setScreenRec] = useState(false);
const [editingTrack, setEditingTrack] = useState(null);
const rulerLaneRef = useRef(null);
const contentRef = useRef(null);
const scrollRef = useRef(null); // the horizontal scroll viewport — for edge auto-scroll while dragging
const [marquee, setMarquee] = useState<{ left: number; top: number; w: number; h: number } | null>(null);
const marqueeActive = useRef(false);
const [hoverPv, setHoverPv] = useState<{ item: MotionItem; rect: DOMRect } | null>(null);
const hoverTimer = useRef | null>(null);
const px = (ms: number) => (Number.isFinite(ms) ? (ms / 1000) * pxps : 0);
const contentW = px(stats.durationMs) + 80;
const seek = (ms: number) => { const c = Math.min(Math.max(0, ms), stats.durationMs); setPlayhead(c); useStore.getState().seekFn?.(c); };
// the magnet guide line is transient — clear it whenever any drag/trim gesture ends
useEffect(() => {
const clear = () => setLine(null);
window.addEventListener("pointerup", clear);
window.addEventListener("pointercancel", clear);
return () => { window.removeEventListener("pointerup", clear); window.removeEventListener("pointercancel", clear); };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// split a specific clip: at the playhead if it's inside the clip, else at the clip's midpoint
const splitClipSmart = (item: { id: string; startMs: number; durationMs: number }) => {
const ph = useStore.getState().playheadMs;
const inside = ph > item.startMs + 100 && ph < item.startMs + item.durationMs - 100;
if (!inside) seek(item.startMs + item.durationMs / 2);
splitClip(item.id);
};
const startScrub = (e: React.PointerEvent) => {
const lane = rulerLaneRef.current;
if (!lane) return;
// live rect so the position stays correct as the viewport auto-scrolls under the cursor
const toMs = (cx: number) => ((cx - lane.getBoundingClientRect().left) / pxps) * 1000;
// touch: don't hijack the native scroll gesture — just seek on a stationary tap
if (e.pointerType === "touch") {
const dx0 = e.clientX, dy0 = e.clientY;
const up = (ev: PointerEvent) => {
window.removeEventListener("pointerup", up);
if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) seek(toMs(ev.clientX));
};
window.addEventListener("pointerup", up);
return;
}
e.preventDefault();
e.stopPropagation();
seek(toMs(e.clientX));
let lastX = e.clientX;
const onMove = (ev: PointerEvent) => { lastX = ev.clientX; seek(toMs(ev.clientX)); };
// drag the playhead/cursor past the edge → keep scrolling so it follows the cursor
let raf = 0;
const tick = () => {
const sc = scrollRef.current;
if (sc && edgeScrollStep(sc, lastX)) seek(toMs(lastX));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onUp = () => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
// The magnet. Aligns a dragged/trimmed clip to the STRONGEST nearby anchor — any other clip's start OR
// end (across EVERY lane, so stacked players line up), the playhead/cursor, or the timeline start/end.
// Both the clip's own start AND end are candidates, so two clips can be made to start OR end together.
// Returns the snapped ms and lights a guide line at the locked time (cleared on pointer-up).
const snapMs = (ms: number, selfId?: string, dur = 0, exclude?: Set): number => {
if (!snap) { setLine(null); return ms; }
const st = useStore.getState();
const skip = (id: string) => id === selfId || (!!exclude && exclude.has(id)); // a moving group must not snap to its own members
const ph = Math.round(st.playheadMs), end = Math.round(stats.durationMs);
// a comfortable ~10px catch zone that stays catchable at any zoom (pro NLEs feel "sticky", not pixel-perfect)
const thresh = Math.max(45, Math.min(420, (10 / pxps) * 1000));
// typed targets so the guide line can colour-code WHAT it locked onto (clip edge vs playhead vs bound)
const targets: { at: number; type: "playhead" | "edge" | "bound" }[] = [
{ at: ph, type: "playhead" }, { at: 0, type: "bound" }, { at: end, type: "bound" },
];
for (const m of st.motion) if (!skip(m.id)) targets.push({ at: m.startMs, type: "edge" }, { at: m.startMs + m.durationMs, type: "edge" });
for (const v of st.videos) if (!skip(v.id)) targets.push({ at: v.startMs, type: "edge" }, { at: v.startMs + v.durationMs, type: "edge" });
// pick the closest hit among "my start → target" and "my end → target"; clip edges win ties over the playhead
let best: { result: number; at: number; type: "playhead" | "edge" | "bound"; dist: number } | null = null;
for (const tg of targets) {
const bias = tg.type === "edge" ? -0.5 : 0; // a hair of preference for clip edges so flush-cuts win ties
const dStart = Math.abs(ms - tg.at);
if (dStart < thresh && (!best || dStart + bias < best.dist)) best = { result: Math.round(tg.at), at: tg.at, type: tg.type, dist: dStart + bias };
if (dur) { const dEnd = Math.abs(ms + dur - tg.at); if (dEnd < thresh && (!best || dEnd + bias < best.dist)) best = { result: Math.round(tg.at - dur), at: tg.at, type: tg.type, dist: dEnd + bias }; }
}
if (best) { setLine({ ms: best.at, type: best.type }); return best.result; }
setLine(null);
return ms;
};
// marquee box-select on a visual lane: drag to highlight a time range → select every scene ON THAT
// LANE that it touches. Shift extends. A plain click (no drag) seeks + deselects.
const startMarqueeFor = (trackId: string) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
const content = contentRef.current;
if (!content) return;
// touch: a box-select drag would fight native scrolling — tap to seek + clear instead
if (e.pointerType === "touch") {
const dx0 = e.clientX, dy0 = e.clientY, additive0 = e.shiftKey;
const up = (ev: PointerEvent) => {
window.removeEventListener("pointerup", up);
if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) {
const r = content.getBoundingClientRect();
seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000);
if (!additive0) clearSelection();
}
};
window.addEventListener("pointerup", up);
return;
}
const additive = e.shiftKey;
const rect0 = content.getBoundingClientRect();
const x0 = e.clientX - rect0.left, y0 = e.clientY - rect0.top;
let moved = false;
marqueeActive.current = true;
document.body.style.userSelect = "none"; // never let a box-select drag highlight page text
if (hoverTimer.current) clearTimeout(hoverTimer.current);
setHoverPv(null);
let lastEv = e.nativeEvent as PointerEvent;
const apply = (ev: PointerEvent) => {
lastEv = ev;
const r = content.getBoundingClientRect();
const x1 = ev.clientX - r.left, y1 = ev.clientY - r.top;
const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
if (!moved && (w > 4 || h > 4)) moved = true;
if (!moved) return;
setMarquee({ left, top, w, h });
// CROSS-TRACK box-select: pick ANY clip (scene or media, on ANY lane) whose rendered rect intersects
// the box — using the live DOM rects so it works across stacked players, not just the lane you started on.
const bx0 = r.left + left, by0 = r.top + top, bx1 = bx0 + w, by1 = by0 + h;
const ids: string[] = [];
content.querySelectorAll("[data-clip-id]").forEach((el) => {
const cr = (el as HTMLElement).getBoundingClientRect();
if (cr.right > bx0 && cr.left < bx1 && cr.bottom > by0 && cr.top < by1) { const id = el.getAttribute("data-clip-id"); if (id) ids.push(id); }
});
setSelection(ids, additive);
};
// keep scrolling while the pointer hovers near a horizontal edge, re-applying so the box follows
let raf = 0;
const tick = () => {
const sc = scrollRef.current;
if (sc && moved) { if (edgeScrollStep(sc, lastEv.clientX)) apply(lastEv); }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onUp = (ev: PointerEvent) => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", apply);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
marqueeActive.current = false;
document.body.style.userSelect = "";
setMarquee(null);
if (!moved) { const r = content.getBoundingClientRect(); seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000); if (!additive) clearSelection(); }
};
window.addEventListener("pointermove", apply);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const onClipHover = (item: ClipLike, el: HTMLElement | null) => {
if (hoverTimer.current) clearTimeout(hoverTimer.current);
const m = useStore.getState().motion.find((x) => x.id === item.id);
if (!el || !m || !m.template || marqueeActive.current) { setHoverPv(null); return; }
const rect = el.getBoundingClientRect();
hoverTimer.current = setTimeout(() => { if (!marqueeActive.current) setHoverPv({ item: m, rect }); }, 260);
};
const visualTracks = tracks.filter((t) => t.kind === "video"); // index 0 = top lane = top z
const audioTracks = tracks.filter((t) => t.kind === "audio");
// background-music drag-and-drop (from the Music panel) → place it as a clip on the Music lane
const hasMusicClip = videos.some((v) => v.music);
const onMusicDragOver = (e: React.DragEvent) => { if (e.dataTransfer.types.includes("application/x-elg-music")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
const onMusicDrop = (e: React.DragEvent) => { const id = e.dataTransfer.getData("application/x-elg-music"); if (id) { e.preventDefault(); setMusic(id); } };
// render one track's lane (header controls + its clips)
const renderTrack = (track: Track, i: number, group: Track[]) => {
const locked = !!track.locked;
const media = videos.filter((v) => v.trackId === track.id && v.kind !== "audio");
const scenes = motion.filter((m) => m.trackId === track.id);
const audioClips = videos.filter((v) => v.trackId === track.id && v.kind === "audio");
const onLaneDown = track.kind === "video" && !locked ? startMarqueeFor(track.id) : startScrub;
return (
0} canDown={i < group.length - 1}
editing={editingTrack === track.id} onEdit={(v) => setEditingTrack(v ? track.id : null)} onLaneDown={onLaneDown}>
{track.kind === "video" && media.map((v) => (
updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} />
))}
{track.kind === "video" && scenes.map((m) => (
updateMotion(id, { trackId: tid })}
sfx={m.sfx !== undefined ? m.sfx : sfxForScene(sfxStyle, m.template)}
groupColor={m.group ? groupColor(m.group) : undefined} multiMove={multiMove}
onUpdate={updateMotion} onRemove={removeMotion} onSelect={onSceneSelect} onHover={onClipHover} onTransition={setTransition}
onSplit={() => splitClipSmart(m)} onDuplicate={() => duplicateMotion(m.id)} selected={selectedIds.includes(m.id)} />
))}
{track.kind === "audio" && audioClips.map((v) => (
updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} audio />
))}
);
};
return (
{selectedIds.length} clips selected— {selectedGrouped ? "grouped: drag any to move all" : "Group to move together, or Combine into one clip"} · ⌘/Shift-click to adjust
{selectedMediaCount > 1 && }
{selectedGrouped && }
)}
{/* height fits the tracks (no dead space below), scrolls when there are many; caps so the preview stays large */}
{/* visual tracks (top → bottom) then audio tracks; the top visual lane composites on top */}
{visualTracks.map((t, i) => renderTrack(t, i, visualTracks))}
{audioTracks.map((t, i) => renderTrack(t, i, audioTracks))}
{/* Music drop hint — shown only until a track is placed; once placed it's a REAL clip on the
Music audio lane (rendered by the audio tracks above) that you can move, trim, cut & fade. */}
{!hasMusicClip && (
Drag a track here from the Music panel — it becomes a clip you can move, trim & fade.
)}
{/* the transcript dialogue lane (driven by the script) — only shown once there's a transcript,
so script/scene-only projects aren't padded with an empty lane */}
{edl.length > 0 && (
{edl.map((it) => (
))}
)}
{marquee && }
{snapLine && }
)}
);
}
// "Add a track" dropdown: a video lane (composites above) or a voice/audio lane (mixes in).
function AddTrackMenu({ onClose }: { onClose: () => void }) {
const addTrack = useStore((s) => s.addTrack);
return (
<>
>
);
}
// Record a voiceover from the mic → upload as an audio asset → drop it on a voice track at the playhead.
function VoiceRecorder({ onClose }: { onClose: () => void }) {
const projectId = useStore((s) => s.projectId);
const addAsset = useStore((s) => s.addAsset);
const addVideo = useStore((s) => s.addVideo);
const [state, setState] = useState<"idle" | "recording" | "saving">("idle");
const [elapsed, setElapsed] = useState(0);
const [err, setErr] = useState("");
const rec = useRef(null);
const stream = useRef(null); // held so the mic is ALWAYS released, even if MediaRecorder() throws
const chunks = useRef([]);
const startedAt = useRef(0);
const timer = useRef | null>(null);
const releaseMic = () => { stream.current?.getTracks().forEach((t) => t.stop()); stream.current = null; };
// teardown (unmount / Cancel / Mic re-toggle): stop the timer and free the mic, discarding any in-flight take
useEffect(() => () => { if (timer.current) clearInterval(timer.current); releaseMic(); }, []);
async function start() {
setErr("");
try {
stream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg"].find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
const mr = new MediaRecorder(stream.current, mime ? { mimeType: mime } : undefined);
chunks.current = [];
mr.ondataavailable = (e) => { if (e.data.size) chunks.current.push(e.data); };
mr.onstop = () => { releaseMic(); upload(new Blob(chunks.current, { type: mr.mimeType || "audio/webm" }), Date.now() - startedAt.current); };
rec.current = mr;
startedAt.current = Date.now();
mr.start();
setState("recording");
timer.current = setInterval(() => setElapsed(Date.now() - startedAt.current), 200);
} catch (e) {
releaseMic(); // construction failed after getUserMedia → don't leave the mic open
setErr(e instanceof Error && e.name === "NotAllowedError" ? "Microphone permission denied." : "Couldn't access the microphone.");
}
}
function stop() {
if (timer.current) clearInterval(timer.current);
const mr = rec.current;
if (!mr || mr.state === "inactive") { releaseMic(); return; }
setState("saving");
mr.stop(); // onstop releases the mic + uploads the take
}
async function upload(blob: Blob, durationMs: number) {
if (!projectId || blob.size === 0) { onClose(); return; }
try {
const ext = (blob.type.includes("ogg") ? "ogg" : "webm");
const name = `Voiceover ${new Date().toLocaleTimeString()}.${ext}`;
const measured = Math.max(500, Math.round(durationMs));
const res = await fetch(`/api/projects/${projectId}/assets?name=${encodeURIComponent(name)}&kind=audio&durationMs=${measured}`, { method: "POST", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob });
const a = await res.json();
if (!a.id) throw new Error(a.error || "upload failed");
const asset = { ...a, kind: "audio" as const, durationMs: Math.max(500, Math.round(durationMs)) }; // trust the measured length
addAsset(asset);
addVideo(asset); // routes to a voice track (created if none) at the playhead
onClose();
} catch (e) {
setErr(e instanceof Error ? e.message : "Couldn't save the recording.");
setState("idle");
}
}
const secs = (elapsed / 1000).toFixed(1);
return (
{state === "idle" && <>Record a voiceover from your mic
>}
{state === "recording" && <>
Recording {secs}s
>}
{state === "saving" && Saving…}
{err && {err}}