/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / App.tsx
312 lines · 9.2 KBCodeBlameHistory
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 1import {useEffect, useRef, useState} from 'react'
2import {useNetwork} from './useNetwork'
4const short = (id: string) => id.slice(0, 8) + '…'
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 6// Screen capture is desktop-only in practice; hide the button where the API
7// doesn't exist (most mobile browsers).
8const canShareScreen =
9 typeof navigator.mediaDevices?.getDisplayMedia === 'function'
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 11const btn: React.CSSProperties = {
12 padding: '0.4rem 1rem',
13 borderRadius: 6,
14 border: '1px solid #888',
15 background: '#fff',
16 cursor: 'pointer',
17 fontSize: '1rem'
20const primaryBtn: React.CSSProperties = {
21 ...btn,
22 background: '#1a7f37',
23 borderColor: '#1a7f37',
24 color: '#fff'
27const dangerBtn: React.CSSProperties = {
28 ...btn,
29 background: '#c62828',
30 borderColor: '#c62828',
31 color: '#fff'
eb841f4Grey out Call/Leave buttons while disabled so calling before joining is clearly blockedJeremy Magland 34// Merged into a button's style whenever it is disabled, so the explicit
35// background colors above don't leave a disabled button looking clickable.
36const disabledStyle: React.CSSProperties = {
37 opacity: 0.4,
38 cursor: 'not-allowed'
42 stream,
43 muted,
44 style
45}: {
46 stream: MediaStream | null
47 muted: boolean
48 style: React.CSSProperties
49}) {
50 const ref = useRef<HTMLVideoElement>(null)
51 useEffect(() => {
52 if (ref.current && ref.current.srcObject !== stream) {
53 ref.current.srcObject = stream
54 }
55 }, [stream])
56 return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
59function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
60 const [name, setName] = useState(initial)
61 const submit = (e: React.FormEvent) => {
62 e.preventDefault()
63 onJoin(name)
64 }
65 return (
66 <form onSubmit={submit} style={{marginTop: '1rem'}}>
67 <p>Enter an ID so other visitors can see you and call you:</p>
68 <input
69 autoFocus
70 value={name}
71 onChange={e => setName(e.target.value)}
72 placeholder="your id"
73 maxLength={40}
74 style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
75 />
76 <button type="submit" style={primaryBtn} disabled={!name.trim()}>
77 Join
78 </button>
79 </form>
80 )
83export default function App() {
84 const {snapshot, network} = useNetwork()
85 const {selfId, name, roster, call, notice} = snapshot
87 const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
89 return (
90 <div
91 style={{
92 fontFamily: 'sans-serif',
93 maxWidth: 720,
94 margin: '2rem auto',
95 padding: '0 1rem'
96 }}
97 >
98 <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
99 <p style={{color: '#666', marginTop: 0}}>
100 Peer-to-peer video calls. No server: presence and call setup ride over
101 public nostr relays; audio/video flows directly over WebRTC.
102 </p>
104 {notice && (
105 <div
106 style={{
107 background: '#fff3cd',
108 border: '1px solid #e0c968',
109 borderRadius: 6,
110 padding: '0.5rem 0.75rem',
111 margin: '0.75rem 0',
112 display: 'flex',
113 justifyContent: 'space-between',
114 alignItems: 'center'
115 }}
116 >
117 <span>{notice}</span>
118 <button style={btn} onClick={() => network.dismissNotice()}>
119 OK
120 </button>
121 </div>
122 )}
124 {!name ? (
125 <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
126 ) : (
127 <div style={{margin: '0.75rem 0', color: '#444'}}>
128 You are <strong>{name}</strong>{' '}
129 <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
130 <button
132 ...btn,
133 fontSize: '0.85rem',
134 padding: '0.2rem 0.6rem',
135 ...(inCall ? disabledStyle : null)
136 }}
138 disabled={inCall}
139 >
140 Leave
141 </button>
142 </div>
143 )}
145 {call?.phase === 'incoming' && (
146 <section
147 style={{
148 border: '2px solid #1a7f37',
149 borderRadius: 8,
150 padding: '1rem',
151 margin: '1rem 0'
152 }}
153 >
154 <p style={{marginTop: 0}}>
155 <strong>{call.peerName}</strong>{' '}
156 <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
157 start a video call with you.
158 </p>
159 <button style={primaryBtn} onClick={() => network.accept()}>
160 Accept
161 </button>{' '}
162 <button style={dangerBtn} onClick={() => network.decline()}>
163 Decline
164 </button>
165 </section>
166 )}
168 {call?.phase === 'outgoing' && (
169 <section
170 style={{
171 border: '1px solid #ccc',
172 borderRadius: 8,
173 padding: '1rem',
174 margin: '1rem 0'
175 }}
176 >
177 <p style={{marginTop: 0}}>
178 Calling <strong>{call.peerName}</strong>… waiting for them to
179 accept.
180 </p>
181 <button style={dangerBtn} onClick={() => network.endCall()}>
182 Cancel
183 </button>
184 </section>
185 )}
187 {inCall && call && (
188 <section
189 style={{
190 background: '#111',
191 borderRadius: 8,
192 padding: '0.75rem',
193 margin: '1rem 0',
194 color: '#eee'
195 }}
196 >
197 <div style={{position: 'relative'}}>
198 <VideoView
199 stream={call.remoteStream}
200 muted={false}
201 style={{
202 width: '100%',
203 aspectRatio: '4 / 3',
204 background: '#000',
205 borderRadius: 6,
206 objectFit: 'cover'
207 }}
208 />
209 <VideoView
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 210 stream={call.screenStream ?? call.localStream}
212 style={{
213 position: 'absolute',
214 right: 10,
215 bottom: 10,
216 width: '25%',
217 background: '#000',
218 border: '1px solid #444',
219 borderRadius: 6,
558b70bAdd screen sharing via in-place video track swap (no renegotiation)Jeremy Magland 220 // Mirror the camera preview, but never the shared screen.
221 transform: call.screenStream ? undefined : 'scaleX(-1)'
223 />
224 </div>
225 <div
226 style={{
227 display: 'flex',
228 justifyContent: 'space-between',
229 alignItems: 'center',
232 }}
233 >
237 ? `Sharing your screen with ${call.peerName}`
238 : `In a call with ${call.peerName}`
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 239 : `Connecting to ${call.peerName}…`}
240 </span>
242 <button
243 style={
244 call.phase === 'connected'
245 ? btn
246 : {...btn, ...disabledStyle}
247 }
248 disabled={call.phase !== 'connected'}
249 onClick={() =>
250 call.screenStream
251 ? void network.stopScreenShare()
252 : void network.startScreenShare()
253 }
254 >
255 {call.screenStream ? 'Stop sharing' : 'Share screen'}
256 </button>
257 )}
1cec293Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signalingJeremy Magland 258 <button style={dangerBtn} onClick={() => network.endCall()}>
259 Hang up
260 </button>
261 </div>
262 </section>
263 )}
265 <section>
266 <h2>Visitors ({roster.length})</h2>
267 {roster.length === 0 ? (
268 <p style={{color: '#666'}}>
269 Nobody else is here right now. Open this page in another browser or
270 send the link to a friend.
271 </p>
272 ) : (
273 <table style={{borderCollapse: 'collapse', width: '100%'}}>
274 <tbody>
275 {roster.map(p => (
276 <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
277 <td style={{padding: '0.4rem'}}>
278 <strong>{p.name}</strong>{' '}
279 <code style={{color: '#999'}}>{short(p.peerId)}</code>
280 </td>
281 <td style={{padding: '0.4rem', color: '#666'}}>
282 {p.busy ? 'in a call' : 'available'}
283 </td>
284 <td style={{padding: '0.4rem', textAlign: 'right'}}>
286 const disabled = !name || call !== null || p.busy
287 return (
288 <button
289 style={disabled ? {...primaryBtn, ...disabledStyle} : primaryBtn}
290 disabled={disabled}
291 title={!name ? 'Enter an ID above to call' : undefined}
292 onClick={() => network.callPeer(p.peerId)}
293 >
294 Call
295 </button>
296 )
297 })()}
299 </tr>
300 ))}
301 </tbody>
302 </table>
303 )}
304 {!name && roster.length > 0 && (
305 <p style={{color: '#666', fontSize: '0.9rem'}}>
306 Enter an ID above to call someone.
307 </p>
308 )}
309 </section>
310 </div>
311 )
moveopenescclose