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