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