1import {useEffect, useRef, useState} from 'react'
2import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
3import {useNetwork} from './useNetwork'
5const short = (id: string) => id.slice(0, 8) + '…'
7// Screen capture is desktop-only in practice; hide the button where the API
8// doesn't exist (most mobile browsers).
9const canShareScreen =
10 typeof navigator.mediaDevices?.getDisplayMedia === 'function'
12const btn: React.CSSProperties = {
13 padding: '0.4rem 1rem',
14 borderRadius: 6,
15 border: '1px solid #888',
16 background: '#fff',
17 cursor: 'pointer',
18 fontSize: '1rem'
19}
21const primaryBtn: React.CSSProperties = {
22 ...btn,
23 background: '#1a7f37',
24 borderColor: '#1a7f37',
25 color: '#fff'
26}
28const dangerBtn: React.CSSProperties = {
29 ...btn,
30 background: '#c62828',
31 borderColor: '#c62828',
32 color: '#fff'
33}
35// Merged into a button's style whenever it is disabled, so the explicit
36// background colors above don't leave a disabled button looking clickable.
37const disabledStyle: React.CSSProperties = {
38 opacity: 0.4,
39 cursor: 'not-allowed'
40}
42// A mute/cam-off button while its mute is engaged.
43const engagedBtn: React.CSSProperties = {
44 ...btn,
45 background: '#555',
46 borderColor: '#555',
47 color: '#fff'
48}
50// Badge overlaid on the remote video reporting the other party's mute state.
51const muteChip: React.CSSProperties = {
52 background: 'rgba(0, 0, 0, 0.65)',
53 color: '#fff',
54 borderRadius: 999,
55 padding: '0.15rem 0.6rem',
56 fontSize: '0.8rem'
57}
59function VideoView({
60 stream,
61 muted,
62 style
63}: {
64 stream: MediaStream | null
65 muted: boolean
66 style: React.CSSProperties
67}) {
68 const ref = useRef<HTMLVideoElement>(null)
69 useEffect(() => {
70 if (ref.current && ref.current.srcObject !== stream) {
71 ref.current.srcObject = stream
72 }
73 }, [stream])
74 return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
75}
77function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
78 const [name, setName] = useState(initial)
79 const submit = (e: React.FormEvent) => {
80 e.preventDefault()
81 onJoin(name)
82 }
83 return (
84 <form onSubmit={submit} style={{marginTop: '1rem'}}>
85 <p>Enter an ID so other visitors can see you and call you:</p>
86 <input
87 autoFocus
88 value={name}
89 onChange={e => setName(e.target.value)}
90 placeholder="your id"
91 maxLength={40}
92 style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
93 />
94 <button type="submit" style={primaryBtn} disabled={!name.trim()}>
95 Join
96 </button>
97 </form>
98 )
99}
101export default function App() {
102 const {snapshot, network} = useNetwork()
103 const {selfId, name, roster, call, notice} = snapshot
105 const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
107 return (
108 <div
109 style={{
110 fontFamily: 'sans-serif',
111 maxWidth: 720,
112 margin: '2rem auto',
113 padding: '0 1rem'
114 }}
115 >
116 <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
117 <p style={{color: '#666', marginTop: 0}}>
118 Peer-to-peer video calls. No server: presence and call setup ride over
119 public nostr relays; audio/video flows directly over WebRTC.
120 </p>
122 {notice && (
123 <div
124 style={{
125 background: '#fff3cd',
126 border: '1px solid #e0c968',
127 borderRadius: 6,
128 padding: '0.5rem 0.75rem',
129 margin: '0.75rem 0',
130 display: 'flex',
131 justifyContent: 'space-between',
132 alignItems: 'center'
133 }}
134 >
135 <span>{notice}</span>
136 <button style={btn} onClick={() => network.dismissNotice()}>
137 OK
138 </button>
139 </div>
140 )}
142 {!name ? (
143 <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
144 ) : (
145 <div style={{margin: '0.75rem 0', color: '#444'}}>
146 You are <strong>{name}</strong>{' '}
147 <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
148 <button
149 style={{
150 ...btn,
151 fontSize: '0.85rem',
152 padding: '0.2rem 0.6rem',
153 ...(inCall ? disabledStyle : null)
154 }}
155 onClick={() => network.leave()}
156 disabled={inCall}
157 >
158 Leave
159 </button>
160 </div>
161 )}
163 {call?.phase === 'incoming' && (
164 <section
165 style={{
166 border: '2px solid #1a7f37',
167 borderRadius: 8,
168 padding: '1rem',
169 margin: '1rem 0'
170 }}
171 >
172 <p style={{marginTop: 0}}>
173 <strong>{call.peerName}</strong>{' '}
174 <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
175 start a video call with you.
176 </p>
177 <button style={primaryBtn} onClick={() => network.accept()}>
178 Accept
179 </button>{' '}
180 <button style={dangerBtn} onClick={() => network.decline()}>
181 Decline
182 </button>
183 </section>
184 )}
186 {call?.phase === 'outgoing' && (
187 <section
188 style={{
189 border: '1px solid #ccc',
190 borderRadius: 8,
191 padding: '1rem',
192 margin: '1rem 0'
193 }}
194 >
195 <p style={{marginTop: 0}}>
196 Calling <strong>{call.peerName}</strong>… waiting for them to
197 accept.
198 </p>
199 <button style={dangerBtn} onClick={() => network.endCall()}>
200 Cancel
201 </button>
202 </section>
203 )}
205 {inCall && call && (
206 <section
207 style={{
208 background: '#111',
209 borderRadius: 8,
210 padding: '0.75rem',
211 margin: '1rem 0',
212 color: '#eee'
213 }}
214 >
215 <div style={{position: 'relative'}}>
216 <VideoView
217 stream={call.remoteStream}
218 muted={false}
219 style={{
220 width: '100%',
221 aspectRatio: '4 / 3',
222 background: '#000',
223 borderRadius: 6,
224 objectFit: 'cover'
225 }}
226 />
227 {(call.peerAudioMuted || call.peerVideoMuted) && (
228 <div
229 style={{
230 position: 'absolute',
231 top: 10,
232 left: 10,
233 display: 'flex',
234 gap: '0.4rem'
235 }}
236 >
237 {call.peerAudioMuted && <span style={muteChip}>mic muted</span>}
238 {call.peerVideoMuted && <span style={muteChip}>camera off</span>}
239 </div>
240 )}
241 <VideoView
242 stream={call.screenStream ?? call.localStream}
243 muted
244 style={{
245 position: 'absolute',
246 right: 10,
247 bottom: 10,
248 width: '25%',
249 background: '#000',
250 border: '1px solid #444',
251 borderRadius: 6,
252 // Mirror the camera preview, but never the shared screen.
253 transform: call.screenStream ? undefined : 'scaleX(-1)'
254 }}
255 />
256 </div>
257 <div
258 style={{
259 display: 'flex',
260 justifyContent: 'space-between',
261 alignItems: 'center',
262 flexWrap: 'wrap',
263 gap: '0.5rem',
264 marginTop: '0.5rem'
265 }}
266 >
267 <span style={{flex: 1}}>
268 {call.phase === 'connected'
269 ? call.screenStream
270 ? `Sharing your screen with ${call.peerName}`
271 : `In a call with ${call.peerName}`
272 : `Connecting to ${call.peerName}…`}
273 </span>
274 <button
275 style={{
276 ...(call.audioMuted ? engagedBtn : btn),
277 ...(call.localStream ? null : disabledStyle)
278 }}
279 disabled={!call.localStream}
280 title={call.audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
281 onClick={() => network.setAudioMuted(!call.audioMuted)}
282 >
283 {call.audioMuted ? 'Unmute' : 'Mute'}
284 </button>
285 <button
286 style={{
287 ...(call.videoMuted ? engagedBtn : btn),
288 ...(call.localStream ? null : disabledStyle)
289 }}
290 disabled={!call.localStream}
291 title={
292 call.videoMuted
293 ? 'Turn your camera back on'
294 : 'Turn your camera off'
295 }
296 onClick={() => network.setVideoMuted(!call.videoMuted)}
297 >
298 {call.videoMuted ? 'Cam on' : 'Cam off'}
299 </button>
300 <label
301 title="Video quality for both directions — either of you can change it"
302 style={{
303 display: 'flex',
304 alignItems: 'center',
305 gap: '0.35rem',
306 fontSize: '0.9rem'
307 }}
308 >
309 Quality
310 <select
311 value={call.settings.videoQuality}
312 disabled={call.phase !== 'connected'}
313 onChange={e =>
314 network.setVideoQuality(e.target.value as VideoQuality)
315 }
316 style={{
317 padding: '0.3rem',
318 borderRadius: 6,
319 border: '1px solid #888',
320 background: '#fff',
321 fontSize: '0.9rem',
322 ...(call.phase !== 'connected' ? disabledStyle : null)
323 }}
324 >
325 {VIDEO_QUALITIES.map(q => (
326 <option key={q} value={q}>
327 {q[0].toUpperCase() + q.slice(1)}
328 </option>
329 ))}
330 </select>
331 </label>
332 {canShareScreen && (
333 <button
334 style={
335 call.phase === 'connected'
336 ? btn
337 : {...btn, ...disabledStyle}
338 }
339 disabled={call.phase !== 'connected'}
340 onClick={() =>
341 call.screenStream
342 ? void network.stopScreenShare()
343 : void network.startScreenShare()
344 }
345 >
346 {call.screenStream ? 'Stop sharing' : 'Share screen'}
347 </button>
348 )}
349 <button style={dangerBtn} onClick={() => network.endCall()}>
350 Hang up
351 </button>
352 </div>
353 </section>
354 )}
356 <section>
357 <h2>Visitors ({roster.length})</h2>
358 {roster.length === 0 ? (
359 <p style={{color: '#666'}}>
360 Nobody else is here right now. Open this page in another browser or
361 send the link to a friend.
362 </p>
363 ) : (
364 <table style={{borderCollapse: 'collapse', width: '100%'}}>
365 <tbody>
366 {roster.map(p => (
367 <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
368 <td style={{padding: '0.4rem'}}>
369 <strong>{p.name}</strong>{' '}
370 <code style={{color: '#999'}}>{short(p.peerId)}</code>
371 </td>
372 <td style={{padding: '0.4rem', color: '#666'}}>
373 {p.busy ? 'in a call' : 'available'}
374 </td>
375 <td style={{padding: '0.4rem', textAlign: 'right'}}>
376 {(() => {
377 const disabled = !name || call !== null || p.busy
378 return (
379 <button
380 style={disabled ? {...primaryBtn, ...disabledStyle} : primaryBtn}
381 disabled={disabled}
382 title={!name ? 'Enter an ID above to call' : undefined}
383 onClick={() => network.callPeer(p.peerId)}
384 >
385 Call
386 </button>
387 )
388 })()}
389 </td>
390 </tr>
391 ))}
392 </tbody>
393 </table>
394 )}
395 {!name && roster.length > 0 && (
396 <p style={{color: '#666', fontSize: '0.9rem'}}>
397 Enter an ID above to call someone.
398 </p>
399 )}
400 </section>
401 </div>
402 )
403}