/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / pages / Prompter.tsx
126 lines · 5.2 KBBlameHistoryRaw
1// Pop-out teleprompter: shows the notes large, with recording controls that drive the recorder
2// window over a BroadcastChannel.
4import { useEffect, useRef, useState } from 'react';
5import type { Video } from '../../shared/types.ts';
6import { api } from '../api.ts';
7import { Icon } from '../components/Icon.tsx';
8import { channelName, type RecCommand, type RecMessage, type RecState } from '../lib/channel.ts';
9import { fmtDuration, loadPref, savePref } from '../lib/format.ts';
11export function Prompter({ videoId }: { videoId: string }) {
12 const [video, setVideo] = useState<Video | null>(null);
13 const [state, setState] = useState<RecState | null>(null);
14 const [view, setViewState] = useState(() => loadPref('takes.prompter', { fontSize: 28, autoScroll: false, speed: 30 }));
15 const channel = useRef<BroadcastChannel | null>(null);
16 const bodyRef = useRef<HTMLDivElement>(null);
18 const setView = (p: Partial<typeof view>) =>
19 setViewState((v) => {
20 const next = { ...v, ...p };
21 savePref('takes.prompter', next);
22 return next;
23 });
25 const reload = () => api.video(videoId).then(setVideo).catch(() => {});
27 useEffect(() => {
28 document.title = 'Teleprompter · Takes';
29 reload();
30 const ch = new BroadcastChannel(channelName(videoId));
31 channel.current = ch;
32 ch.onmessage = (ev: MessageEvent<RecMessage>) => {
33 if (ev.data.type === 'state') setState(ev.data.state);
34 else if (ev.data.type === 'closed') setState(null);
35 };
36 ch.postMessage({ type: 'command', cmd: 'hello' } satisfies RecMessage);
37 return () => ch.close();
38 }, [videoId]); // eslint-disable-line react-hooks/exhaustive-deps
40 const send = (cmd: RecCommand) => channel.current?.postMessage({ type: 'command', cmd } satisfies RecMessage);
42 // Auto-scroll while recording.
43 const recording = state?.phase === 'recording';
44 useEffect(() => {
45 if (!recording || !view.autoScroll) return;
46 let raf = 0;
47 let last = performance.now();
48 let acc = 0;
49 const loop = (t: number) => {
50 acc += ((t - last) / 1000) * view.speed;
51 last = t;
52 const whole = Math.floor(acc);
53 if (whole > 0 && bodyRef.current) {
54 bodyRef.current.scrollTop += whole;
55 acc -= whole;
56 }
57 raf = requestAnimationFrame(loop);
58 };
59 raf = requestAnimationFrame(loop);
60 return () => cancelAnimationFrame(raf);
61 }, [recording, view.autoScroll, view.speed]);
63 const phase = state?.phase;
64 return (
65 <div className="prompter">
66 <div className="prompter-bar">
67 {!state && <span className="muted">Recorder window not open</span>}
68 {phase === 'setup' && <span className="muted">Ready</span>}
69 {phase === 'countdown' && <span className="rec-indicator">Starting in {state!.countdown}…</span>}
70 {(phase === 'recording' || phase === 'paused') && (
71 <span className={`rec-indicator ${phase}`}>
72 <span className="rec-dot" /> {phase === 'paused' ? 'Paused' : 'REC'} <span className="timecode">{fmtDuration(state!.elapsed)}</span>
73 </span>
74 )}
75 {phase === 'processing' && <span className="muted">Saving…</span>}
76 <div className="spacer" />
77 {phase === 'setup' && (
78 <button className="btn record" disabled={!state?.canStart} onClick={() => send('start')}>
79 <Icon name="record" /> Start
80 </button>
81 )}
82 {phase === 'countdown' && (
83 <button className="btn" onClick={() => send('stop')}>
84 Cancel
85 </button>
86 )}
87 {phase === 'recording' && (
88 <button className="btn" onClick={() => send('pause')}>
89 <Icon name="pause" /> Pause
90 </button>
91 )}
92 {phase === 'paused' && (
93 <button className="btn" onClick={() => send('resume')}>
94 <Icon name="record" /> Resume
95 </button>
96 )}
97 {(phase === 'recording' || phase === 'paused') && (
98 <button className="btn primary" onClick={() => send('stop')}>
99 <Icon name="stop" /> Stop
100 </button>
101 )}
102 </div>
103 {state?.message && <div className="banner">{state.message}</div>}
104 <div className="prompter-tools">
105 <button className="btn small" onClick={() => setView({ fontSize: Math.max(14, view.fontSize - 2) })}>
106 A−
107 </button>
108 <button className="btn small" onClick={() => setView({ fontSize: Math.min(72, view.fontSize + 2) })}>
109 A+
110 </button>
111 <label className="check small">
112 <input type="checkbox" checked={view.autoScroll} onChange={(e) => setView({ autoScroll: e.target.checked })} /> Auto-scroll while recording
113 </label>
114 {view.autoScroll && <input type="range" min={5} max={120} value={view.speed} onChange={(e) => setView({ speed: parseInt(e.target.value) })} title="Scroll speed" />}
115 <div className="spacer" />
116 <button className="btn small ghost" onClick={reload} title="Reload notes">
117 Reload notes
118 </button>
119 </div>
120 <div className="prompter-body" ref={bodyRef} style={{ fontSize: view.fontSize }}>
121 {video ? video.notes || <span className="muted">No notes for this video.</span> : 'Loading…'}
122 <div style={{ height: '60vh' }} />
123 </div>
124 </div>
125 );
↑↓ move↵openescclose