Add screen sharing via in-place video track swap (no renegotiation)
5 changed files+96−6
CLAUDE.mdmodified+4−0View file
@@ -31,6 +31,10 @@ src/App.tsx join form, roster, ring/accept UI, video views
3131 - **Deterministic initiator.** The smaller peer ID creates the offer, same as
3232 commonview — no perfect-negotiation glare handling. Both sides add their
3333 tracks before signaling starts so one offer/answer round covers all media.
34+- **Screen share = track swap.** `getDisplayMedia` + `RTCRtpSender.replaceTrack`
35+ replaces the camera track in place (screen instead of camera, not alongside).
36+ Same-kind replacement avoids renegotiation, which the one-offer design cannot
37+ do — never addTrack mid-call.
3438
3539 ## Testing
3640
README.mdmodified+1−1View file
@@ -7,7 +7,7 @@ Serverless peer-to-peer video calls in the browser.
77 Visit the page, enter an ID, and you'll see the IDs of everyone else currently
88 on the page. Click a visitor to request a call; once they accept — both users
99 must agree — a direct WebRTC connection is established and audio/video flows
10-peer-to-peer.
10+peer-to-peer. During a call you can share your screen in place of your camera.
1111
1212 ## How it works
1313
src/App.tsxmodified+30−4View file
@@ -3,6 +3,11 @@ import {useNetwork} from './useNetwork'
33
44 const short = (id: string) => id.slice(0, 8) + '…'
55
6+// Screen capture is desktop-only in practice; hide the button where the API
7+// doesn't exist (most mobile browsers).
8+const canShareScreen =
9+ typeof navigator.mediaDevices?.getDisplayMedia === 'function'
10+
611 const btn: React.CSSProperties = {
712 padding: '0.4rem 1rem',
813 borderRadius: 6,
@@ -202,7 +207,7 @@ export default function App() {
202207 }}
203208 />
204209 <VideoView
205- stream={call.localStream}
210+ stream={call.screenStream ?? call.localStream}
206211 muted
207212 style={{
208213 position: 'absolute',
@@ -212,7 +217,8 @@ export default function App() {
212217 background: '#000',
213218 border: '1px solid #444',
214219 borderRadius: 6,
215- transform: 'scaleX(-1)'
220+ // Mirror the camera preview, but never the shared screen.
221+ transform: call.screenStream ? undefined : 'scaleX(-1)'
216222 }}
217223 />
218224 </div>
@@ -221,14 +227,34 @@ export default function App() {
221227 display: 'flex',
222228 justifyContent: 'space-between',
223229 alignItems: 'center',
230+ gap: '0.5rem',
224231 marginTop: '0.5rem'
225232 }}
226233 >
227- <span>
234+ <span style={{flex: 1}}>
228235 {call.phase === 'connected'
229- ? `In a call with ${call.peerName}`
236+ ? call.screenStream
237+ ? `Sharing your screen with ${call.peerName}`
238+ : `In a call with ${call.peerName}`
230239 : `Connecting to ${call.peerName}…`}
231240 </span>
241+ {canShareScreen && (
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+ )}
232258 <button style={dangerBtn} onClick={() => network.endCall()}>
233259 Hang up
234260 </button>
src/p2p/network.tsmodified+46−1View file
@@ -53,6 +53,8 @@ interface Call {
5353 peer: Peer | null
5454 localStream: MediaStream | null
5555 remoteStream: MediaStream | null
56+ /** Set while the outgoing video track is a screen capture, not the camera. */
57+ screenStream: MediaStream | null
5658 /** Signals that arrived before our getUserMedia resolved. */
5759 pendingSignals: Signal[]
5860 ringInterval: number | null
@@ -72,6 +74,7 @@ export interface CallInfo {
7274 peerName: string
7375 localStream: MediaStream | null
7476 remoteStream: MediaStream | null
77+ screenStream: MediaStream | null
7578 }
7679
7780 export interface Snapshot {
@@ -263,6 +266,7 @@ export class Network {
263266 peer: null,
264267 localStream: null,
265268 remoteStream: null,
269+ screenStream: null,
266270 pendingSignals: [],
267271 ringInterval: null,
268272 ringTimeout: null,
@@ -366,6 +370,9 @@ export class Network {
366370 if (call.localStream) {
367371 for (const track of call.localStream.getTracks()) track.stop()
368372 }
373+ if (call.screenStream) {
374+ for (const track of call.screenStream.getTracks()) track.stop()
375+ }
369376 this.notice = notice
370377 this.rebuildSnapshot()
371378 void this.announce()
@@ -441,6 +448,43 @@ export class Network {
441448 this.teardown(null)
442449 }
443450
451+ /** Swap the outgoing camera track for a screen capture. The remote side
452+ * sees the screen in place of the camera; no renegotiation involved. */
453+ async startScreenShare() {
454+ const call = this.call
455+ if (!call || !call.peer || call.screenStream) return
456+ let stream: MediaStream
457+ try {
458+ stream = await navigator.mediaDevices.getDisplayMedia({video: true})
459+ } catch {
460+ return // user canceled the picker (or capture is unsupported)
461+ }
462+ const track = stream.getVideoTracks()[0]
463+ const ok =
464+ this.call === call && call.peer && track
465+ ? await call.peer.replaceVideoTrack(track)
466+ : false
467+ if (!ok || this.call !== call) {
468+ for (const t of stream.getTracks()) t.stop()
469+ return
470+ }
471+ call.screenStream = stream
472+ // The browser's own "Stop sharing" bar ends the track; swap back then.
473+ track.onended = () => void this.stopScreenShare()
474+ this.rebuildSnapshot()
475+ }
476+
477+ async stopScreenShare() {
478+ const call = this.call
479+ if (!call || !call.screenStream) return
480+ const screen = call.screenStream
481+ call.screenStream = null
482+ const camTrack = call.localStream?.getVideoTracks()[0]
483+ if (call.peer && camTrack) await call.peer.replaceVideoTrack(camTrack)
484+ for (const t of screen.getTracks()) t.stop()
485+ if (this.call === call) this.rebuildSnapshot()
486+ }
487+
444488 dismissNotice() {
445489 this.notice = null
446490 this.rebuildSnapshot()
@@ -466,7 +510,8 @@ export class Network {
466510 peerId: this.call.peerId,
467511 peerName: this.call.peerName,
468512 localStream: this.call.localStream,
469- remoteStream: this.call.remoteStream
513+ remoteStream: this.call.remoteStream,
514+ screenStream: this.call.screenStream
470515 }
471516 : null
472517 this.snapshot = {
src/p2p/peer.tsmodified+15−0View file
@@ -171,6 +171,21 @@ export class Peer {
171171 if (this.channel?.readyState === 'open') this.channel.send(data)
172172 }
173173
174+ /** Swap the outgoing video track in place (camera ↔ screen). A same-kind
175+ * replaceTrack does not trigger renegotiation, so no signaling is needed
176+ * and the one-offer design is preserved. */
177+ async replaceVideoTrack(track: MediaStreamTrack): Promise<boolean> {
178+ if (this.closed) return false
179+ const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
180+ if (!sender) return false
181+ try {
182+ await sender.replaceTrack(track)
183+ return true
184+ } catch {
185+ return false
186+ }
187+ }
188+
174189 get isConnected(): boolean {
175190 return this.pc.connectionState === 'connected'
176191 }