DEV Community

Oleh Apostol
Oleh Apostol

Posted on

Minimal WebRTC: building a 1:1 call where camera, mic, and screen are independent tracks

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 })
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Defer when solo. If you're the only participant, create the RTCPeerConnection and add local tracks, but don't send an offer. Wait for the participant-joined signaling message, then offer.
  2. 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 });
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

What I'd do differently

  • Use onnegotiationneeded with 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 RTCDataChannel instead 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)