1import { useEffect, useRef, useState } from 'react';
2import type { PublicConfig, TrackKind, Video } from '../../shared/types.ts';
3import { navigate } from '../App.tsx';
4import { api } from '../api.ts';
5import { Icon } from '../components/Icon.tsx';
6import { MicMeter } from '../components/MicMeter.tsx';
7import { channelName, type RecMessage, type RecPhase, type RecState } from '../lib/channel.ts';
8import { fmtDuration, loadPref, savePref } from '../lib/format.ts';
9import { RecordingSession } from '../lib/recorder.ts';
11interface Prefs {
12 useCamera: boolean;
13 cameraId: string;
14 cameraHeight: 720 | 1080;
15 useMic: boolean;
16 micId: string;
17 audioProcessing: boolean;
18 useScreen: boolean;
19 countdown: number;
20}
22const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
24function stopStream(s: MediaStream | null) {
25 s?.getTracks().forEach((t) => t.stop());
26}
28function VideoPreview({ stream, label }: { stream: MediaStream | null; label: string }) {
29 const ref = useRef<HTMLVideoElement>(null);
30 useEffect(() => {
31 if (ref.current) ref.current.srcObject = stream;
32 }, [stream]);
33 return (
34 <div className="preview">
35 {stream ? <video ref={ref} autoPlay muted playsInline /> : <div className="preview-empty">{label}</div>}
36 </div>
37 );
38}
40export function Recorder({ videoId }: { videoId: string }) {
41 const [video, setVideo] = useState<Video | null>(null);
42 const [prefs, setPrefsState] = useState<Prefs | null>(null);
43 const [cams, setCams] = useState<MediaDeviceInfo[]>([]);
44 const [mics, setMics] = useState<MediaDeviceInfo[]>([]);
45 const [camStream, setCamStream] = useState<MediaStream | null>(null);
46 const [micStream, setMicStream] = useState<MediaStream | null>(null);
47 const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
48 const [phase, setPhase] = useState<RecPhase>('setup');
49 const [countdown, setCountdown] = useState(0);
50 const [elapsed, setElapsed] = useState(0);
51 const [error, setError] = useState<string | null>(null);
52 const [message, setMessage] = useState<string | undefined>();
54 const session = useRef<RecordingSession | null>(null);
55 const takeId = useRef<string | null>(null);
56 const aborted = useRef(false);
57 const clock = useRef({ start: 0, pausedAt: 0, pausedTotal: 0 });
58 const phaseRef = useRef(phase);
59 phaseRef.current = phase;
61 // Load video, and prefs (localStorage, falling back to app defaults).
62 useEffect(() => {
63 api.video(videoId).then((v) => {
64 setVideo(v);
65 document.title = `Recording: ${v.title} · Takes`;
66 }).catch((e) => setError(e.message));
67 api
68 .config()
69 .catch(() => null)
70 .then((cfg: PublicConfig | null) => {
71 setPrefsState(
72 loadPref<Prefs>('takes.recorder', {
73 useCamera: true,
74 cameraId: '',
75 cameraHeight: cfg?.defaultCameraHeight ?? 720,
76 useMic: true,
77 micId: '',
78 audioProcessing: true,
79 useScreen: true,
80 countdown: cfg?.defaultCountdown ?? 3,
81 }),
82 );
83 });
84 }, [videoId]);
86 const setPrefs = (p: Partial<Prefs>) =>
87 setPrefsState((cur) => {
88 const next = { ...cur!, ...p };
89 savePref('takes.recorder', next);
90 return next;
91 });
93 const refreshDevices = async () => {
94 const devs = await navigator.mediaDevices.enumerateDevices();
95 setCams(devs.filter((d) => d.kind === 'videoinput' && d.deviceId));
96 setMics(devs.filter((d) => d.kind === 'audioinput' && d.deviceId));
97 };
98 useEffect(() => {
99 navigator.mediaDevices.addEventListener('devicechange', refreshDevices);
100 return () => navigator.mediaDevices.removeEventListener('devicechange', refreshDevices);
101 }, []);
103 // Camera stream follows the camera settings.
104 useEffect(() => {
105 if (!prefs?.useCamera) return;
106 let stream: MediaStream | null = null;
107 let cancelled = false;
108 const h = prefs.cameraHeight;
109 navigator.mediaDevices
110 .getUserMedia({
111 video: { deviceId: prefs.cameraId ? { exact: prefs.cameraId } : undefined, width: { ideal: (h * 16) / 9 }, height: { ideal: h }, frameRate: { ideal: 30 } },
112 })
113 .then((s) => {
114 if (cancelled) return stopStream(s);
115 stream = s;
116 setCamStream(s);
117 refreshDevices();
118 })
119 .catch((e) => setError(`Camera: ${e.message}`));
120 return () => {
121 cancelled = true;
122 stopStream(stream);
123 setCamStream(null);
124 };
125 }, [prefs?.useCamera, prefs?.cameraId, prefs?.cameraHeight]);
127 // Microphone stream follows the mic settings.
128 useEffect(() => {
129 if (!prefs?.useMic) return;
130 let stream: MediaStream | null = null;
131 let cancelled = false;
132 const proc = prefs.audioProcessing;
133 navigator.mediaDevices
134 .getUserMedia({
135 audio: { deviceId: prefs.micId ? { exact: prefs.micId } : undefined, echoCancellation: proc, noiseSuppression: proc, autoGainControl: proc },
136 })
137 .then((s) => {
138 if (cancelled) return stopStream(s);
139 stream = s;
140 setMicStream(s);
141 refreshDevices();
142 })
143 .catch((e) => setError(`Microphone: ${e.message}`));
144 return () => {
145 cancelled = true;
146 stopStream(stream);
147 setMicStream(null);
148 };
149 }, [prefs?.useMic, prefs?.micId, prefs?.audioProcessing]);
151 // Screen stream is stopped when leaving the page.
152 const screenRef = useRef<MediaStream | null>(null);
153 screenRef.current = screenStream;
154 useEffect(() => () => stopStream(screenRef.current), []);
156 const chooseScreen = async (): Promise<MediaStream | null> => {
157 try {
158 const s = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
159 stopStream(screenRef.current);
160 s.getVideoTracks()[0].addEventListener('ended', () => {
161 setScreenStream((cur) => (cur === s ? null : cur));
162 if (phaseRef.current === 'recording' || phaseRef.current === 'paused') stopRef.current();
163 });
164 setScreenStream(s);
165 return s;
166 } catch (e) {
167 if ((e as Error).name !== 'NotAllowedError') setError(`Screen: ${(e as Error).message}`);
168 return null;
169 }
170 };
172 const useCamera = !!prefs?.useCamera;
173 const useScreen = !!prefs?.useScreen;
174 const useMic = !!prefs?.useMic;
175 const devicesReady = (useCamera || useScreen) && (!useCamera || !!camStream) && (!useMic || !!micStream);
176 const canStart = phase === 'setup' && devicesReady;
178 // ---- recording control ----
179 const begin = async (screen: MediaStream | null) => {
180 if (!prefs || !devicesReady || phaseRef.current !== 'setup') return;
181 if (useScreen && !screen) {
182 setMessage('Choose the screen or window to record in the main window first.');
183 return;
184 }
185 setError(null);
186 setMessage(undefined);
187 aborted.current = false;
188 setPhase('countdown');
189 try {
190 const take = await api.createTake(videoId, {
191 tracks: { screen: useScreen, camera: useCamera },
192 audio: useMic ? (useCamera ? 'camera' : 'screen') : null,
193 });
194 takeId.current = take.id;
195 for (let n = prefs.countdown; n > 0; n--) {
196 setCountdown(n);
197 await sleep(1000);
198 if (aborted.current) {
199 await api.deleteTake(videoId, take.id).catch(() => {});
200 setPhase('setup');
201 return;
202 }
203 }
204 setCountdown(0);
205 const micTracks = useMic && micStream ? micStream.getAudioTracks() : [];
206 const streams: Partial<Record<TrackKind, MediaStream>> = {};
207 if (useCamera && camStream) streams.camera = new MediaStream([...camStream.getVideoTracks(), ...micTracks]);
208 if (useScreen && screen) streams.screen = new MediaStream([...screen.getVideoTracks(), ...(useCamera ? [] : micTracks)]);
209 session.current = new RecordingSession(videoId, take.id, streams, prefs.cameraHeight);
210 session.current.start();
211 clock.current = { start: performance.now(), pausedAt: 0, pausedTotal: 0 };
212 setElapsed(0);
213 setPhase('recording');
214 } catch (e) {
215 setError((e as Error).message);
216 setPhase('setup');
217 }
218 };
220 const pause = () => {
221 if (phaseRef.current !== 'recording') return;
222 session.current?.pause();
223 clock.current.pausedAt = performance.now();
224 setPhase('paused');
225 };
227 const resume = () => {
228 if (phaseRef.current !== 'paused') return;
229 session.current?.resume();
230 clock.current.pausedTotal += performance.now() - clock.current.pausedAt;
231 setPhase('recording');
232 };
234 const stop = async () => {
235 const p = phaseRef.current;
236 if (p === 'countdown') {
237 aborted.current = true;
238 return;
239 }
240 if (p !== 'recording' && p !== 'paused') return;
241 setPhase('processing');
242 const s = session.current;
243 session.current = null;
244 try {
245 await s?.stop();
246 await api.finalizeTake(videoId, takeId.current!);
247 navigate(`video/${videoId}`);
248 } catch (e) {
249 setError(`${(e as Error).message}. The take was kept; you can try "Recover" on the video page.`);
250 setPhase('setup');
251 }
252 };
253 const stopRef = useRef(stop);
254 stopRef.current = stop;
256 const discard = async () => {
257 const p = phaseRef.current;
258 if (p !== 'recording' && p !== 'paused') return;
259 setPhase('processing');
260 const s = session.current;
261 session.current = null;
262 await s?.stop().catch(() => {});
263 await api.deleteTake(videoId, takeId.current!).catch(() => {});
264 setPhase('setup');
265 };
267 // Elapsed time while recording.
268 useEffect(() => {
269 if (phase !== 'recording') return;
270 const iv = setInterval(() => {
271 const c = clock.current;
272 setElapsed((performance.now() - c.start - c.pausedTotal) / 1000);
273 }, 200);
274 return () => clearInterval(iv);
275 }, [phase]);
277 // Warn before closing the window mid-recording.
278 useEffect(() => {
279 if (phase === 'setup') return;
280 const on = (e: BeforeUnloadEvent) => e.preventDefault();
281 window.addEventListener('beforeunload', on);
282 return () => window.removeEventListener('beforeunload', on);
283 }, [phase]);
285 // ---- teleprompter channel ----
286 const channel = useRef<BroadcastChannel | null>(null);
287 const handlers = useRef({ begin: () => {}, pause, resume, stop });
288 handlers.current = { begin: () => begin(screenStream), pause, resume, stop };
289 const state: RecState = { phase, elapsed, countdown, canStart, message };
290 const stateRef = useRef(state);
291 stateRef.current = state;
293 useEffect(() => {
294 const ch = new BroadcastChannel(channelName(videoId));
295 channel.current = ch;
296 ch.onmessage = (ev: MessageEvent<RecMessage>) => {
297 const m = ev.data;
298 if (m.type !== 'command') return;
299 if (m.cmd === 'hello') ch.postMessage({ type: 'state', state: stateRef.current } satisfies RecMessage);
300 else if (m.cmd === 'start') handlers.current.begin();
301 else handlers.current[m.cmd]();
302 };
303 return () => {
304 ch.postMessage({ type: 'closed' } satisfies RecMessage);
305 ch.close();
306 };
307 }, [videoId]);
309 useEffect(() => {
310 channel.current?.postMessage({ type: 'state', state } satisfies RecMessage);
311 }, [phase, Math.floor(elapsed * 4), countdown, canStart, message]); // eslint-disable-line react-hooks/exhaustive-deps
313 const openPrompter = () => {
314 window.open(`#/prompter/${videoId}`, `takes-prompter-${videoId}`, 'popup,width=620,height=800');
315 };
317 // Keyboard: Space pauses/resumes while recording.
318 useEffect(() => {
319 const on = (e: KeyboardEvent) => {
320 if ((e.target as HTMLElement).closest('input, textarea, select')) return;
321 if (e.key === ' ' && (phaseRef.current === 'recording' || phaseRef.current === 'paused')) {
322 e.preventDefault();
323 (phaseRef.current === 'recording' ? handlers.current.pause : handlers.current.resume)();
324 }
325 };
326 window.addEventListener('keydown', on);
327 return () => window.removeEventListener('keydown', on);
328 }, []);
330 if (!prefs || !video) return <div className="page loading">{error ?? 'Loading…'}</div>;
332 const recording = phase === 'recording' || phase === 'paused';
333 const locked = phase !== 'setup';
335 return (
336 <div className="page recorder">
337 <header className="topbar">
338 <button className="btn ghost" disabled={locked} onClick={() => navigate(`video/${videoId}`)}>
339 <Icon name="back" /> Back
340 </button>
341 <div className="topbar-title">
342 <span className="muted">Recording</span> {video.title}
343 </div>
344 <div className="spacer" />
345 <button className="btn" onClick={openPrompter} title="Open notes and recording controls in a separate window">
346 <Icon name="popout" /> Teleprompter window
347 </button>
348 </header>
350 {error && (
351 <div className="banner error" onClick={() => setError(null)}>
352 {error}
353 </div>
354 )}
355 {message && <div className="banner">{message}</div>}
357 <div className="recorder-grid">
358 <section className="panel sources">
359 <div className="source">
360 <label className="check">
361 <input type="checkbox" checked={useCamera} disabled={locked} onChange={(e) => setPrefs({ useCamera: e.target.checked })} />
362 <Icon name="camera" /> Camera
363 </label>
364 {useCamera && (
365 <div className="source-opts">
366 <select value={prefs.cameraId} disabled={locked} onChange={(e) => setPrefs({ cameraId: e.target.value })}>
367 <option value="">Default camera</option>
368 {cams.map((d) => (
369 <option key={d.deviceId} value={d.deviceId}>
370 {d.label || 'Camera'}
371 </option>
372 ))}
373 </select>
374 <select value={prefs.cameraHeight} disabled={locked} onChange={(e) => setPrefs({ cameraHeight: parseInt(e.target.value) as 720 | 1080 })}>
375 <option value={720}>720p</option>
376 <option value={1080}>1080p</option>
377 </select>
378 </div>
379 )}
380 </div>
382 <div className="source">
383 <label className="check">
384 <input type="checkbox" checked={useMic} disabled={locked} onChange={(e) => setPrefs({ useMic: e.target.checked })} />
385 <Icon name="mic" /> Microphone
386 </label>
387 {useMic && (
388 <div className="source-opts">
389 <select value={prefs.micId} disabled={locked} onChange={(e) => setPrefs({ micId: e.target.value })}>
390 <option value="">Default microphone</option>
391 {mics.map((d) => (
392 <option key={d.deviceId} value={d.deviceId}>
393 {d.label || 'Microphone'}
394 </option>
395 ))}
396 </select>
397 <label className="check small" title="Echo cancellation, noise suppression, and automatic gain">
398 <input type="checkbox" checked={prefs.audioProcessing} disabled={locked} onChange={(e) => setPrefs({ audioProcessing: e.target.checked })} />
399 Noise suppression
400 </label>
401 </div>
402 )}
403 {useMic && <MicMeter stream={micStream} />}
404 </div>
406 <div className="source">
407 <label className="check">
408 <input type="checkbox" checked={useScreen} disabled={locked} onChange={(e) => setPrefs({ useScreen: e.target.checked })} />
409 <Icon name="screen" /> Screen
410 </label>
411 {useScreen && (
412 <div className="source-opts">
413 <button className="btn small" disabled={recording} onClick={chooseScreen}>
414 {screenStream ? 'Change screen or window…' : 'Choose screen or window…'}
415 </button>
416 {screenStream && !locked && (
417 <button
418 className="btn small ghost"
419 onClick={() => {
420 stopStream(screenStream);
421 setScreenStream(null);
422 }}
423 >
424 Stop sharing
425 </button>
426 )}
427 </div>
428 )}
429 </div>
431 <div className="source">
432 <label className="row">
433 <span>Countdown</span>
434 <select value={prefs.countdown} disabled={locked} onChange={(e) => setPrefs({ countdown: parseInt(e.target.value) })}>
435 {[0, 3, 5, 10].map((n) => (
436 <option key={n} value={n}>
437 {n === 0 ? 'None' : `${n} s`}
438 </option>
439 ))}
440 </select>
441 </label>
442 </div>
444 <div className="previews">
445 {useCamera && <VideoPreview stream={camStream} label="Starting camera…" />}
446 {useScreen && <VideoPreview stream={screenStream} label="No screen chosen yet" />}
447 </div>
449 <div className="rec-controls">
450 {phase === 'setup' && (
451 <button
452 className="btn record big"
453 disabled={!canStart}
454 onClick={async () => {
455 let s = screenStream;
456 if (useScreen && !s) s = await chooseScreen();
457 if (!useScreen || s) begin(s);
458 }}
459 >
460 <Icon name="record" /> Start recording
461 </button>
462 )}
463 {phase === 'countdown' && (
464 <button className="btn big" onClick={stop}>
465 Cancel
466 </button>
467 )}
468 {recording && (
469 <>
470 <div className={`rec-indicator ${phase}`}>
471 <span className="rec-dot" /> {phase === 'paused' ? 'Paused' : 'Recording'} <span className="timecode">{fmtDuration(elapsed)}</span>
472 </div>
473 {phase === 'recording' ? (
474 <button className="btn big" onClick={pause}>
475 <Icon name="pause" /> Pause
476 </button>
477 ) : (
478 <button className="btn big" onClick={resume}>
479 <Icon name="record" /> Resume
480 </button>
481 )}
482 <button className="btn primary big" onClick={stop}>
483 <Icon name="stop" /> Stop and save
484 </button>
485 <button className="btn ghost danger" onClick={discard}>
486 Discard
487 </button>
488 </>
489 )}
490 {phase === 'processing' && <div className="muted">Saving take…</div>}
491 </div>
492 {phase === 'setup' && !devicesReady && (useCamera || useScreen) && <p className="muted small">Waiting for devices…</p>}
493 {!useCamera && !useScreen && <p className="warn-text small">Enable the camera, the screen, or both.</p>}
494 <p className="muted small">Space pauses and resumes while this window is focused. When recording your whole screen, move the teleprompter window to another monitor or record a single window.</p>
495 </section>
497 <section className="panel notes-panel">
498 <h4>Notes</h4>
499 <div className="notes-view">{video.notes || <span className="muted">No notes. Add them on the video page.</span>}</div>
500 </section>
501 </div>
503 {phase === 'countdown' && countdown > 0 && (
504 <div className="countdown-overlay">
505 <div className="countdown-num">{countdown}</div>
506 </div>
507 )}
508 </div>
509 );
510}