Most WebRTC tutorials show one getUserMedia stream with audio+video, one RTCPeerConnection, and a happy path. I shipped Peer — a no-account 1:1 call app with screen sharing — and the interesting work was everything the tutorials skip: camera, mic, and screen as separate toggles on a single peer connection, with renegotiation on every flip, and telling screen from camera on the receiving end.
Here's the condensed version.
The model: three streams, one connection
Instead of one combined local stream, keep three:
let screenStream: MediaStream | null = null; // getDisplayMedia
let micStream: MediaStream | null = null; // getUserMedia({ audio: true })
let cameraStream: MediaStream | null = null; // getUserMedia({ video: true })
Each toggle adds that stream's tracks to the same RTCPeerConnection:
stream.getTracks().forEach(track => {
const alreadyAdded = pc.getSenders().some(s => s.track === track);
if (!alreadyAdded) pc.addTrack(track, stream);
});
Why separate streams instead of one stream with track.enabled? Because "not sharing" and "muted" are different promises. A stopped track that was never added to the connection means the remote side — and the browser's permission indicators — treat it as absent, not silenced.
Renegotiation: don't trust onnegotiationneeded blindly
Every add/remove requires a new offer. The trap: if the first peer creates an offer while alone in the room, the signaling server has nobody to forward it to — the offer is silently lost, and when the second person joins, nobody re-offers.
Two rules fixed it:
-
Defer when solo. If you're the only participant, create the
RTCPeerConnectionand add local tracks, but don't send an offer. Wait for theparticipant-joinedsignaling message, then offer. -
Re-offer when remote state exists. If
pc.remoteDescription !== null, any track change means you owe the peer a fresh offer:
async function renegotiate(pc: RTCPeerConnection, send: (m: any) => void) {
if (pc.signalingState === 'have-local-offer') {
await pc.setLocalDescription({ type: 'rollback' });
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
send({ type: 'offer', offer });
}
The rollback line matters: if a previous offer is still in flight when the user toggles something, createOffer throws without it.
And the receiving side must add its local tracks before creating the answer, or the answer won't include them:
case 'offer':
await pc.setRemoteDescription(new RTCSessionDescription(msg.offer));
ensureAllLocalTracksAdded(pc); // add first, then answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
send({ type: 'answer', answer });
break;
Telling screen from camera on the receiving end
ontrack hands you a video track. Is it the screen or the camera? The track won't say. With camera+screen both live, you get two video tracks and need to route them to different UI elements.
My layered heuristic:
function getVideoTrackRole(track: MediaStreamTrack): 'screen' | 'camera' {
if (track.contentHint === 'motion') return 'camera'; // we set this on send
const settings = track.getSettings();
if (settings.displaySurface) return 'screen'; // display capture marker
if (track.contentHint === 'detail' || track.contentHint === 'text') return 'screen';
const label = (track.label || '').toLowerCase();
if (/screen|display|window|desktop|monitor/.test(label)) return 'screen';
return 'camera';
}
On the send side, I set hints so the receiver has the best chance:
screenStream.getVideoTracks().forEach(t => (t.contentHint = 'detail'));
cameraStream.getVideoTracks().forEach(t => (t.contentHint = 'motion'));
contentHint also influences encoding — detail optimizes for crisp text, motion for smooth faces — so this pulls double duty.
One catch: getSettings().displaySurface isn't reliably populated across browsers on received tracks, and labels vary ("Screen 1", "FaceTime HD Camera", localized strings). The fallback that saves you: when both video tracks sniff as "camera", assume stream order — screen first, camera second — because the sender added them in that order.
Handling "user pressed the browser's Stop sharing button"
getDisplayMedia tracks end when the user stops sharing from the browser chrome. Listen for it, remove the senders, and tell the peer explicitly (don't make them infer from a dead track):
track.addEventListener('ended', () => {
pc.getSenders()
.filter(s => s.track && stream.getTracks().includes(s.track))
.forEach(s => pc.removeTrack(s));
send({ type: 'participant-media-stop', channel: 'screen' });
renegotiate(pc, send);
});
The explicit participant-media-stop message lets the receiver drop the right slot immediately instead of waiting for track state to settle — important when screen and camera tracks arrive close together.
ICE: queue candidates until remoteDescription exists
Candidates can arrive before you've set the remote description. Buffer them:
if (pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
pending.push(candidate); // flush on 'stable' / after setRemoteDescription
}
What I'd do differently
- Use
onnegotiationneededwith perfect-negotiation pattern from the start instead of hand-rolled offer logic. It handles glare for free; my manual version needed rollback surgery later. - Send a track-to-channel map over a
RTCDataChannelinstead of heuristics. Labels are a smell; an explicit message is a contract.
The whole thing runs at peer.pw — free, no account, rooms expire after 12 hours. Media is P2P; the server only does signaling and falls back to a TURN relay when direct connection fails.
Questions and war stories welcome in the comments.
Top comments (0)