eldouma-graphics / src /components /ScreenRecorder.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
7.26 kB
import { useEffect, useRef, useState } from "react";
import { Monitor, Square, MousePointer2 } from "lucide-react";
import { useStore } from "../store";
import { startScreenCapture, type CaptureController, type CaptureResult, type CaptureQuality } from "../lib/screenCapture";
import { ZOOM_STYLES, type ZoomStyle } from "../lib/screencastZoom";
// Record the screen (preferring this tab) → upload as a video asset → drop it on the
// timeline at the playhead, carrying the captured screencast metadata so the renderer
// can drive the auto-zoom "explainer" camera. Mirrors VoiceRecorder's upload + place flow.
export function ScreenRecorder({ onClose }: { onClose: () => void }) {
const projectId = useStore((s) => s.projectId);
const addAsset = useStore((s) => s.addAsset);
const [state, setState] = useState<"idle" | "recording" | "saving">("idle");
const [elapsed, setElapsed] = useState(0);
const [err, setErr] = useState("");
const [quality, setQuality] = useState<CaptureQuality>("1440p");
const [fps, setFps] = useState<30 | 60>(30);
// how eagerly the explainer camera zooms — default "calm" so it commits to a few
// meaningful shots instead of pushing in on every click
const [cameraStyle, setCameraStyle] = useState<ZoomStyle>("calm");
// default OFF: keep the user's REAL recorded cursor and only paint click effects on it
const [animatedCursor, setAnimatedCursor] = useState(false);
const ctrl = useRef<CaptureController | null>(null);
const startedAt = useRef(0);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const release = () => { ctrl.current?.cancel(); ctrl.current = null; };
// teardown (unmount / Cancel / re-toggle): stop the timer and free the capture, discarding any in-flight take
useEffect(() => () => { if (timer.current) clearInterval(timer.current); release(); }, []);
async function start() {
setErr("");
try {
const c = await startScreenCapture({ quality, frameRate: fps, animatedCursor, cameraStyle });
ctrl.current = c;
startedAt.current = Date.now();
setState("recording");
timer.current = setInterval(() => setElapsed(Date.now() - startedAt.current), 200);
// if the user ends sharing via the browser UI, the controller auto-stops — but the
// resulting CaptureResult is only delivered through our own stop(), so we don't await it here.
} catch (e) {
release();
setErr(e instanceof Error && e.name === "NotAllowedError" ? "Screen-share permission denied." : "Couldn't start screen capture.");
}
}
async function stop() {
if (timer.current) clearInterval(timer.current);
const c = ctrl.current;
if (!c) { onClose(); return; }
setState("saving");
try {
const result = await c.stop();
ctrl.current = null;
await upload(result);
} catch (e) {
setErr(e instanceof Error ? e.message : "Couldn't save the recording.");
setState("idle");
}
}
async function upload(result: CaptureResult) {
if (!projectId || result.blob.size === 0) { onClose(); return; }
try {
const name = `Screen recording ${new Date().toLocaleTimeString()}.webm`;
const measured = Math.max(500, Math.round(result.durationMs));
const res = await fetch(`/api/projects/${projectId}/assets?name=${encodeURIComponent(name)}&kind=video&durationMs=${measured}`, { method: "POST", headers: { "Content-Type": result.blob.type || "video/webm" }, body: result.blob });
const a = await res.json();
if (!a.id) throw new Error(a.error || "upload failed");
const asset = { ...a, kind: "video" as const, durationMs: measured, width: a.width || result.width, height: a.height || result.height };
addAsset(asset);
// place at the playhead exactly like an imported video; placeAsset returns the new clip id
const newClipId = useStore.getState().placeAsset(asset.id, useStore.getState().playheadMs);
// attach the captured screencast metadata so the explainer camera can render
if (newClipId) useStore.getState().setScreencast(newClipId, result.screencast); // requires store.setScreencast
onClose();
} catch (e) {
setErr(e instanceof Error ? e.message : "Couldn't save the recording.");
setState("idle");
}
}
const secs = (elapsed / 1000).toFixed(1);
return (
<div className="flex items-center gap-3 border-b border-border bg-surface-raised px-3 py-2 text-[13px]">
<Monitor size={15} className={state === "recording" ? "text-destructive" : "text-primary"} />
{state === "idle" && <><span className="text-fg">Record your screen — adds an auto-zoom explainer camera</span>
<label className="flex items-center gap-1 text-fg-muted">Quality
<select value={quality} onChange={(e) => setQuality(e.target.value as CaptureQuality)} className="rounded border border-border bg-surface px-1.5 py-0.5 text-[12px] text-fg">
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="4k">4K Ultra HD</option>
</select></label>
<label className="flex items-center gap-1 text-fg-muted">FPS
<select value={fps} onChange={(e) => setFps(Number(e.target.value) as 30 | 60)} className="rounded border border-border bg-surface px-1.5 py-0.5 text-[12px] text-fg">
<option value={30}>30</option>
<option value={60}>60</option>
</select></label>
<label className="flex items-center gap-1 text-fg-muted" title={ZOOM_STYLES.find((s) => s.id === cameraStyle)?.hint}>Camera
<select value={cameraStyle} onChange={(e) => setCameraStyle(e.target.value as ZoomStyle)} className="rounded border border-border bg-surface px-1.5 py-0.5 text-[12px] text-fg">
{ZOOM_STYLES.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select></label>
<button onClick={() => setAnimatedCursor((v) => !v)} title="Replace the real cursor with a smooth animated one (avoids a double cursor)" className={`flex items-center gap-1 rounded-md border px-2 py-1 text-[12px] ${animatedCursor ? "border-primary text-primary" : "border-border text-fg-muted"}`}>
<MousePointer2 size={12} /> Animated cursor</button>
<button onClick={start} className="rounded-md bg-primary px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-105">Start</button></>}
{state === "recording" && <>
<span className="flex items-center gap-1.5 text-fg"><span className="h-2 w-2 animate-pulse rounded-full bg-destructive" /> Recording <span className="num">{secs}s</span></span>
<button onClick={stop} className="flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-110"><Square size={11} /> Stop &amp; place</button></>}
{state === "saving" && <span className="text-fg-subtle">Saving…</span>}
{err && <span className="text-[12px] text-destructive">{err}</span>}
<div className="flex-1" />
<button onClick={onClose} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">{state === "recording" ? "Cancel" : "Close"}</button>
</div>
);
}