Spaces:
Running
Running
| 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 & 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> | |
| ); | |
| } | |