concept-collection / commoncall
commoncall / src / App.tsx
533 lines · 15.8 KBBlameHistoryRaw
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'
21const primaryBtn: React.CSSProperties = {
22 ...btn,
23 background: '#1a7f37',
24 borderColor: '#1a7f37',
25 color: '#fff'
28const dangerBtn: React.CSSProperties = {
29 ...btn,
30 background: '#c62828',
31 borderColor: '#c62828',
32 color: '#fff'
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'
42// Square icon-only buttons for the in-call bar. Their meaning is carried by
43// the icon plus a title tooltip and aria-label.
44const iconBtn: React.CSSProperties = {
45 ...btn,
46 padding: '0.45rem',
47 display: 'inline-flex',
48 alignItems: 'center',
49 justifyContent: 'center'
52// An icon button whose state is engaged (muted / sharing).
53const engagedIconBtn: React.CSSProperties = {
54 ...iconBtn,
55 background: '#555',
56 borderColor: '#555',
57 color: '#fff'
60const dangerIconBtn: React.CSSProperties = {
61 ...iconBtn,
62 background: '#c62828',
63 borderColor: '#c62828',
64 color: '#fff'
67// Badge overlaid on the remote video reporting the other party's mute state.
68const muteChip: React.CSSProperties = {
69 background: 'rgba(0, 0, 0, 0.65)',
70 color: '#fff',
71 borderRadius: 999,
72 padding: '0.3rem',
73 display: 'inline-flex',
74 alignItems: 'center'
77// Stroke-style icon paths (Feather icons, MIT), drawn with currentColor.
78const ICONS = {
79 mic: (
80 <>
81 <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
82 <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
83 <path d="M12 19v4" />
84 <path d="M8 23h8" />
85 </>
86 ),
87 micOff: (
88 <>
89 <path d="M1 1l22 22" />
90 <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
91 <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
92 <path d="M12 19v4" />
93 <path d="M8 23h8" />
94 </>
95 ),
96 video: (
97 <>
98 <path d="M23 7l-7 5 7 5V7z" />
99 <rect x="1" y="5" width="15" height="14" rx="2" />
100 </>
101 ),
102 videoOff: (
103 <>
104 <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" />
105 <path d="M1 1l22 22" />
106 </>
107 ),
108 monitor: (
109 <>
110 <rect x="2" y="3" width="20" height="14" rx="2" />
111 <path d="M8 21h8" />
112 <path d="M12 17v4" />
113 </>
114 ),
115 phone: (
116 <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
117 )
118} as const
120function Icon({
121 name,
122 size = 18,
123 style
124}: {
125 name: keyof typeof ICONS
126 size?: number
127 style?: React.CSSProperties
128}) {
129 return (
130 <svg
131 width={size}
132 height={size}
133 viewBox="0 0 24 24"
134 fill="none"
135 stroke="currentColor"
136 strokeWidth={2}
137 strokeLinecap="round"
138 strokeLinejoin="round"
139 style={{display: 'block', ...style}}
140 aria-hidden
141 >
142 {ICONS[name]}
143 </svg>
144 )
147function VideoView({
148 stream,
149 muted,
150 style
151}: {
152 stream: MediaStream | null
153 muted: boolean
154 style: React.CSSProperties
155}) {
156 const ref = useRef<HTMLVideoElement>(null)
157 useEffect(() => {
158 if (ref.current && ref.current.srcObject !== stream) {
159 ref.current.srcObject = stream
160 }
161 }, [stream])
162 return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
165function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
166 const [name, setName] = useState(initial)
167 const submit = (e: React.FormEvent) => {
168 e.preventDefault()
169 onJoin(name)
170 }
171 return (
172 <form onSubmit={submit} style={{marginTop: '1rem'}}>
173 <p>Enter an ID so other visitors can see you and call you:</p>
174 <input
175 autoFocus
176 value={name}
177 onChange={e => setName(e.target.value)}
178 placeholder="your id"
179 maxLength={40}
180 style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
181 />
182 <button type="submit" style={primaryBtn} disabled={!name.trim()}>
183 Join
184 </button>
185 </form>
186 )
189export default function App() {
190 const {snapshot, network} = useNetwork()
191 const {selfId, name, roster, call, notice} = snapshot
193 const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
195 return (
196 <div
197 style={{
198 fontFamily: 'sans-serif',
199 maxWidth: 720,
200 margin: '2rem auto',
201 padding: '0 1rem'
202 }}
203 >
204 <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
205 <p style={{color: '#666', marginTop: 0}}>
206 Peer-to-peer video calls. No server: presence and call setup ride over
207 public nostr relays; audio/video flows directly over WebRTC.
208 </p>
210 {notice && (
211 <div
212 style={{
213 background: '#fff3cd',
214 border: '1px solid #e0c968',
215 borderRadius: 6,
216 padding: '0.5rem 0.75rem',
217 margin: '0.75rem 0',
218 display: 'flex',
219 justifyContent: 'space-between',
220 alignItems: 'center'
221 }}
222 >
223 <span>{notice}</span>
224 <button style={btn} onClick={() => network.dismissNotice()}>
225 OK
226 </button>
227 </div>
228 )}
230 {!name ? (
231 <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
232 ) : (
233 <div style={{margin: '0.75rem 0', color: '#444'}}>
234 You are <strong>{name}</strong>{' '}
235 <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
236 <button
237 style={{
238 ...btn,
239 fontSize: '0.85rem',
240 padding: '0.2rem 0.6rem',
241 ...(inCall ? disabledStyle : null)
242 }}
243 onClick={() => network.leave()}
244 disabled={inCall}
245 >
246 Leave
247 </button>
248 </div>
249 )}
251 {call?.phase === 'incoming' && (
252 <section
253 style={{
254 border: '2px solid #1a7f37',
255 borderRadius: 8,
256 padding: '1rem',
257 margin: '1rem 0'
258 }}
259 >
260 <p style={{marginTop: 0}}>
261 <strong>{call.peerName}</strong>{' '}
262 <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
263 start a video call with you.
264 </p>
265 <button style={primaryBtn} onClick={() => network.accept()}>
266 Accept
267 </button>{' '}
268 <button style={dangerBtn} onClick={() => network.decline()}>
269 Decline
270 </button>
271 </section>
272 )}
274 {call?.phase === 'outgoing' && (
275 <section
276 style={{
277 border: '1px solid #ccc',
278 borderRadius: 8,
279 padding: '1rem',
280 margin: '1rem 0'
281 }}
282 >
283 <p style={{marginTop: 0}}>
284 Calling <strong>{call.peerName}</strong>… waiting for them to
285 accept.
286 </p>
287 <button style={dangerBtn} onClick={() => network.endCall()}>
288 Cancel
289 </button>
290 </section>
291 )}
293 {inCall && call && (
294 <section
295 style={{
296 background: '#111',
297 borderRadius: 8,
298 padding: '0.75rem',
299 margin: '1rem 0',
300 color: '#eee'
301 }}
302 >
303 <div style={{position: 'relative'}}>
304 <VideoView
305 stream={call.remoteStream}
306 muted={false}
307 style={{
308 width: '100%',
309 aspectRatio: '4 / 3',
310 background: '#000',
311 borderRadius: 6,
312 objectFit: 'cover'
313 }}
314 />
315 {(call.peerAudioMuted || call.peerVideoMuted) && (
316 <div
317 style={{
318 position: 'absolute',
319 top: 10,
320 left: 10,
321 display: 'flex',
322 gap: '0.4rem'
323 }}
324 >
325 {call.peerAudioMuted && (
326 <span
327 style={muteChip}
328 title={`${call.peerName} muted their microphone`}
329 >
330 <Icon name="micOff" size={14} />
331 </span>
332 )}
333 {call.peerVideoMuted && (
334 <span
335 style={muteChip}
336 title={`${call.peerName} turned their camera off`}
337 >
338 <Icon name="videoOff" size={14} />
339 </span>
340 )}
341 </div>
342 )}
343 <VideoView
344 stream={call.screenStream ?? call.localStream}
345 muted
346 style={{
347 position: 'absolute',
348 right: 10,
349 bottom: 10,
350 width: '25%',
351 background: '#000',
352 border: '1px solid #444',
353 borderRadius: 6,
354 // Mirror the camera preview, but never the shared screen.
355 transform: call.screenStream ? undefined : 'scaleX(-1)'
356 }}
357 />
358 </div>
359 <div
360 style={{
361 display: 'flex',
362 justifyContent: 'space-between',
363 alignItems: 'center',
364 flexWrap: 'wrap',
365 gap: '0.5rem',
366 marginTop: '0.5rem'
367 }}
368 >
369 <span style={{flex: 1}}>
370 {call.phase === 'connected'
371 ? call.screenStream
372 ? `Sharing your screen with ${call.peerName}`
373 : `In a call with ${call.peerName}`
374 : `Connecting to ${call.peerName}…`}
375 </span>
376 <button
377 style={{
378 ...(call.audioMuted ? engagedIconBtn : iconBtn),
379 ...(call.localStream ? null : disabledStyle)
380 }}
381 disabled={!call.localStream}
382 title={
383 call.audioMuted
384 ? 'Unmute your microphone'
385 : 'Mute your microphone'
386 }
387 aria-label={
388 call.audioMuted
389 ? 'Unmute your microphone'
390 : 'Mute your microphone'
391 }
392 onClick={() => network.setAudioMuted(!call.audioMuted)}
393 >
394 <Icon name={call.audioMuted ? 'micOff' : 'mic'} />
395 </button>
396 <button
397 style={{
398 ...(call.videoMuted ? engagedIconBtn : iconBtn),
399 ...(call.localStream ? null : disabledStyle)
400 }}
401 disabled={!call.localStream}
402 title={
403 call.videoMuted
404 ? 'Turn your camera back on'
405 : 'Turn your camera off'
406 }
407 aria-label={
408 call.videoMuted
409 ? 'Turn your camera back on'
410 : 'Turn your camera off'
411 }
412 onClick={() => network.setVideoMuted(!call.videoMuted)}
413 >
414 <Icon name={call.videoMuted ? 'videoOff' : 'video'} />
415 </button>
416 <label
417 title="Video quality for both directions — either of you can change it"
418 style={{
419 display: 'flex',
420 alignItems: 'center',
421 gap: '0.35rem',
422 fontSize: '0.9rem'
423 }}
424 >
425 Quality
426 <select
427 value={call.settings.videoQuality}
428 disabled={call.phase !== 'connected'}
429 onChange={e =>
430 network.setVideoQuality(e.target.value as VideoQuality)
431 }
432 style={{
433 padding: '0.3rem',
434 borderRadius: 6,
435 border: '1px solid #888',
436 background: '#fff',
437 fontSize: '0.9rem',
438 ...(call.phase !== 'connected' ? disabledStyle : null)
439 }}
440 >
441 {VIDEO_QUALITIES.map(q => (
442 <option key={q} value={q}>
443 {q[0].toUpperCase() + q.slice(1)}
444 </option>
445 ))}
446 </select>
447 </label>
448 {canShareScreen && (
449 <button
450 style={{
451 ...(call.screenStream ? engagedIconBtn : iconBtn),
452 ...(call.phase === 'connected' ? null : disabledStyle)
453 }}
454 disabled={call.phase !== 'connected'}
455 title={
456 call.screenStream
457 ? 'Stop sharing your screen'
458 : 'Share your screen'
459 }
460 aria-label={
461 call.screenStream
462 ? 'Stop sharing your screen'
463 : 'Share your screen'
464 }
465 onClick={() =>
466 call.screenStream
467 ? void network.stopScreenShare()
468 : void network.startScreenShare()
469 }
470 >
471 <Icon name="monitor" />
472 </button>
473 )}
474 <button
475 style={dangerIconBtn}
476 title="Hang up"
477 aria-label="Hang up"
478 onClick={() => network.endCall()}
479 >
480 <Icon name="phone" style={{transform: 'rotate(135deg)'}} />
481 </button>
482 </div>
483 </section>
484 )}
486 <section>
487 <h2>Visitors ({roster.length})</h2>
488 {roster.length === 0 ? (
489 <p style={{color: '#666'}}>
490 Nobody else is here right now. Open this page in another browser or
491 send the link to a friend.
492 </p>
493 ) : (
494 <table style={{borderCollapse: 'collapse', width: '100%'}}>
495 <tbody>
496 {roster.map(p => (
497 <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
498 <td style={{padding: '0.4rem'}}>
499 <strong>{p.name}</strong>{' '}
500 <code style={{color: '#999'}}>{short(p.peerId)}</code>
501 </td>
502 <td style={{padding: '0.4rem', color: '#666'}}>
503 {p.busy ? 'in a call' : 'available'}
504 </td>
505 <td style={{padding: '0.4rem', textAlign: 'right'}}>
506 {(() => {
507 const disabled = !name || call !== null || p.busy
508 return (
509 <button
510 style={disabled ? {...primaryBtn, ...disabledStyle} : primaryBtn}
511 disabled={disabled}
512 title={!name ? 'Enter an ID above to call' : undefined}
513 onClick={() => network.callPeer(p.peerId)}
514 >
515 Call
516 </button>
517 )
518 })()}
519 </td>
520 </tr>
521 ))}
522 </tbody>
523 </table>
524 )}
525 {!name && roster.length > 0 && (
526 <p style={{color: '#666', fontSize: '0.9rem'}}>
527 Enter an ID above to call someone.
528 </p>
529 )}
530 </section>
531 </div>
532 )