Media

Add microphone, camera, and screen tracks to an OpenRTC connection

OpenRTC gives every peer connection a WebRTC-familiar media API. Your app asks the browser or native platform for a microphone or camera, then adds those tracks to connection.media. OpenRTC handles the encoded media protocol, encryption, framing, recovery, and carrier changes.

camera or microphone
  -> OpenRTC media track
  -> protected logical Iroh connection
  -> iroh-quic, webrtc, or moq carrier
  -> remote media track

The carrier is an internal path, not a second connection. Your app does not create SDP, exchange ICE candidates, open a MoQ session, or add tracks again when OpenRTC changes carriers.

Browser quickstart

Start with the rtc client from the Quickstart, then join a room for the call:

<video id="local-video" autoplay muted playsinline></video>
<video id="remote-video" autoplay playsinline></video>
const room = await rtc.rooms.join('call-123');

const localVideo = document.querySelector<HTMLVideoElement>('#local-video')!;
const remoteVideo = document.querySelector<HTMLVideoElement>('#remote-video')!;

const localMedia = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true,
});
localVideo.srcObject = localMedia;

// This simple example is a one-to-one call. Use one MediaStream per peer for a
// group call.
const remoteMedia = new MediaStream();
remoteVideo.srcObject = remoteMedia;

const stopConnections = room.onConnection(async (connection) => {
  const stopTracks = connection.media.onTrack(({ track }) => {
    for (const current of remoteMedia.getTracks()) {
      if (current.kind === track.kind) remoteMedia.removeTrack(current);
    }
    remoteMedia.addTrack(track);
  });

  connection.onClose(stopTracks);

  for (const track of localMedia.getTracks()) {
    await connection.media.addTrack(track);
  }
});

onConnection() reports the stable logical peer. If a physical Iroh path is replaced, OpenRTC reopens the active publications after the new generation is ready. The callback does not become a reconnect owner.

When the user ends the call, release the browser devices and the OpenRTC room:

stopConnections();
localMedia.getTracks().forEach((track) => track.stop());
await room.leave();
await rtc.close();

Call controls

Inside an onConnection() callback, addTrack() returns a sender. Keep it when the UI needs mute, camera switching, screen sharing, statistics, or an explicit stop:

const cameraTrack = localMedia.getVideoTracks()[0];
const camera = await connection.media.addTrack(cameraTrack);

await camera.setEnabled(false); // mute this outgoing track

const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = display.getVideoTracks()[0];
await camera.replaceTrack(screenTrack); // keep the publication, change its source
console.log(await camera.getStats());
await camera.stop();
display.getTracks().forEach((track) => track.stop());

Use connection.media.getStats() for all senders and receivers on the peer. The latency values are bounded local pipeline measurements, not glass-to-glass latency between devices.

Browser support

Camera and microphone access requires HTTPS or localhost plus user permission. Browser media also needs WebCodecs and a supported track processing/rendering edge. OpenRTC uses worker and Web Audio fallbacks where the browser exposes equivalent APIs. An unsupported browser fails clearly instead of silently creating a separate RTCPeerConnection media lifecycle.

Native Rust

The openrtc crate works without TypeScript. Native applications provide an encoded MediaSource for capture and encoding, and a MediaSink for decoding and rendering. Those platform edges never open sockets or choose carriers:

use openrtc::media::MediaSource;
use std::sync::Arc;

let media = room.media_connection(Arc::clone(&client), peer_id)?;
let source: Box<dyn MediaSource> = Box::new(MyEncodedCamera::new()?);
let mut sender = media.add_track(source).await?;

while sender.send_next().await? {
    // The source controls capture pacing. OpenRTC owns delivery and recovery.
}

The app's one incoming-stream dispatcher passes the selected media stream to MediaConnection::accept_incoming(). Calling render_next() validates the generation, integrity, and replay state before the host's MediaSink sees a chunk.

In Tauri, import OpenRTC from openrtc/native and use the openrtc-tauri IPC bridge. The WebView keeps the same TypeScript media surface, while the native Rust client owns the endpoint and peer lifecycle; the Tauri path does not start a second WASM connection manager.

For a complete multi-participant setup and a live, origin-gated room, see Build a video meeting. The demo uses actual OpenRTC peers and media tracks; it does not simulate other participants in the browser.

See Advanced native for setup and Media, streams, and datagrams for every method, statistics field, codec rule, and the lower-level stream APIs.