/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / App.tsx
346 lines · 10.4 KBCodeBlameHistory
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 1import {useEffect, useRef, useState} from 'react'
1eb9884video quality settingsJeremy Magland 2import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 3import {useNetwork} from './useNetwork'
5const short = (id: string) => id.slice(0, 8) + '…'
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 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'
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 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'
eb841f4Grey out Call/Leave buttons while disabled so calling before joining is clearly blockedJeremy Magland 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'
43 stream,
44 muted,
45 style
46}: {
47 stream: MediaStream | null
48 muted: boolean
49 style: React.CSSProperties
50}) {
51 const ref = useRef<HTMLVideoElement>(null)
52 useEffect(() => {
53 if (ref.current && ref.current.srcObject !== stream) {
54 ref.current.srcObject = stream
55 }
56 }, [stream])
57 return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
60function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
61 const [name, setName] = useState(initial)
62 const submit = (e: React.FormEvent) => {
63 e.preventDefault()
64 onJoin(name)
65 }
66 return (
67 <form onSubmit={submit} style={{marginTop: '1rem'}}>
68 <p>Enter an ID so other visitors can see you and call you:</p>
69 <input
70 autoFocus
71 value={name}
72 onChange={e => setName(e.target.value)}
73 placeholder="your id"
74 maxLength={40}
75 style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
76 />
77 <button type="submit" style={primaryBtn} disabled={!name.trim()}>
78 Join
79 </button>
80 </form>
81 )
84export default function App() {
85 const {snapshot, network} = useNetwork()
86 const {selfId, name, roster, call, notice} = snapshot
88 const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
90 return (
91 <div
92 style={{
93 fontFamily: 'sans-serif',
94 maxWidth: 720,
95 margin: '2rem auto',
96 padding: '0 1rem'
97 }}
98 >
99 <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
100 <p style={{color: '#666', marginTop: 0}}>
101 Peer-to-peer video calls. No server: presence and call setup ride over
102 public nostr relays; audio/video flows directly over WebRTC.
103 </p>
105 {notice && (
106 <div
107 style={{
108 background: '#fff3cd',
109 border: '1px solid #e0c968',
110 borderRadius: 6,
111 padding: '0.5rem 0.75rem',
112 margin: '0.75rem 0',
113 display: 'flex',
114 justifyContent: 'space-between',
115 alignItems: 'center'
116 }}
117 >
118 <span>{notice}</span>
119 <button style={btn} onClick={() => network.dismissNotice()}>
120 OK
121 </button>
122 </div>
123 )}
125 {!name ? (
126 <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
127 ) : (
128 <div style={{margin: '0.75rem 0', color: '#444'}}>
129 You are <strong>{name}</strong>{' '}
130 <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
131 <button
133 ...btn,
134 fontSize: '0.85rem',
135 padding: '0.2rem 0.6rem',
136 ...(inCall ? disabledStyle : null)
137 }}
139 disabled={inCall}
140 >
141 Leave
142 </button>
143 </div>
144 )}
146 {call?.phase === 'incoming' && (
147 <section
148 style={{
149 border: '2px solid #1a7f37',
150 borderRadius: 8,
151 padding: '1rem',
152 margin: '1rem 0'
153 }}
154 >
155 <p style={{marginTop: 0}}>
156 <strong>{call.peerName}</strong>{' '}
157 <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
158 start a video call with you.
159 </p>
160 <button style={primaryBtn} onClick={() => network.accept()}>
161 Accept
162 </button>{' '}
163 <button style={dangerBtn} onClick={() => network.decline()}>
164 Decline
165 </button>
166 </section>
167 )}
169 {call?.phase === 'outgoing' && (
170 <section
171 style={{
172 border: '1px solid #ccc',
173 borderRadius: 8,
174 padding: '1rem',
175 margin: '1rem 0'
176 }}
177 >
178 <p style={{marginTop: 0}}>
179 Calling <strong>{call.peerName}</strong>… waiting for them to
180 accept.
181 </p>
182 <button style={dangerBtn} onClick={() => network.endCall()}>
183 Cancel
184 </button>
185 </section>
186 )}
188 {inCall && call && (
189 <section
190 style={{
191 background: '#111',
192 borderRadius: 8,
193 padding: '0.75rem',
194 margin: '1rem 0',
195 color: '#eee'
196 }}
197 >
198 <div style={{position: 'relative'}}>
199 <VideoView
200 stream={call.remoteStream}
201 muted={false}
202 style={{
203 width: '100%',
204 aspectRatio: '4 / 3',
205 background: '#000',
206 borderRadius: 6,
207 objectFit: 'cover'
208 }}
209 />
210 <VideoView
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 211 stream={call.screenStream ?? call.localStream}
213 style={{
214 position: 'absolute',
215 right: 10,
216 bottom: 10,
217 width: '25%',
218 background: '#000',
219 border: '1px solid #444',
220 borderRadius: 6,
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 221 // Mirror the camera preview, but never the shared screen.
222 transform: call.screenStream ? undefined : 'scaleX(-1)'
224 />
225 </div>
226 <div
227 style={{
228 display: 'flex',
229 justifyContent: 'space-between',
230 alignItems: 'center',
1eb9884video quality settingsJeremy Magland 231 flexWrap: 'wrap',
234 }}
235 >
239 ? `Sharing your screen with ${call.peerName}`
240 : `In a call with ${call.peerName}`
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 241 : `Connecting to ${call.peerName}…`}
242 </span>
1eb9884video quality settingsJeremy Magland 243 <label
244 title="Video quality for both directions — either of you can change it"
245 style={{
246 display: 'flex',
247 alignItems: 'center',
248 gap: '0.35rem',
249 fontSize: '0.9rem'
250 }}
251 >
252 Quality
253 <select
254 value={call.settings.videoQuality}
255 disabled={call.phase !== 'connected'}
256 onChange={e =>
257 network.setVideoQuality(e.target.value as VideoQuality)
258 }
259 style={{
260 padding: '0.3rem',
261 borderRadius: 6,
262 border: '1px solid #888',
263 background: '#fff',
264 fontSize: '0.9rem',
265 ...(call.phase !== 'connected' ? disabledStyle : null)
266 }}
267 >
268 {VIDEO_QUALITIES.map(q => (
269 <option key={q} value={q}>
270 {q[0].toUpperCase() + q.slice(1)}
271 </option>
272 ))}
273 </select>
274 </label>
276 <button
277 style={
278 call.phase === 'connected'
279 ? btn
280 : {...btn, ...disabledStyle}
281 }
282 disabled={call.phase !== 'connected'}
283 onClick={() =>
284 call.screenStream
285 ? void network.stopScreenShare()
286 : void network.startScreenShare()
287 }
288 >
289 {call.screenStream ? 'Stop sharing' : 'Share screen'}
290 </button>
291 )}
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 292 <button style={dangerBtn} onClick={() => network.endCall()}>
293 Hang up
294 </button>
295 </div>
296 </section>
297 )}
299 <section>
300 <h2>Visitors ({roster.length})</h2>
301 {roster.length === 0 ? (
302 <p style={{color: '#666'}}>
303 Nobody else is here right now. Open this page in another browser or
304 send the link to a friend.
305 </p>
306 ) : (
307 <table style={{borderCollapse: 'collapse', width: '100%'}}>
308 <tbody>
309 {roster.map(p => (
310 <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
311 <td style={{padding: '0.4rem'}}>
312 <strong>{p.name}</strong>{' '}
313 <code style={{color: '#999'}}>{short(p.peerId)}</code>
314 </td>
315 <td style={{padding: '0.4rem', color: '#666'}}>
316 {p.busy ? 'in a call' : 'available'}
317 </td>
318 <td style={{padding: '0.4rem', textAlign: 'right'}}>
320 const disabled = !name || call !== null || p.busy
321 return (
322 <button
323 style={disabled ? {...primaryBtn, ...disabledStyle} : primaryBtn}
324 disabled={disabled}
325 title={!name ? 'Enter an ID above to call' : undefined}
326 onClick={() => network.callPeer(p.peerId)}
327 >
328 Call
329 </button>
330 )
331 })()}
333 </tr>
334 ))}
335 </tbody>
336 </table>
337 )}
338 {!name && roster.length > 0 && (
339 <p style={{color: '#666', fontSize: '0.9rem'}}>
340 Enter an ID above to call someone.
341 </p>
342 )}
343 </section>
344 </div>
345 )
moveopenescclose