WebRtc
Media and data sent directly between two browsers: peer connections, data channels, and the statistics that say what is actually happening on the wire.
@inject Bit.Butil.WebRtc webRtcMDN reference
RTCPeerConnection is in every current engine. During prerender/SSR the check returns false rather than throwing, so defer it to OnAfterRenderAsync.
@inject Bit.Butil.WebRtc webRtc
var supported = await webRtc.IsSupported();The whole handshake
CreatePeerConnection / CreateOffer / SetLocalDescription / SetRemoteDescription / AddIceCandidateOne side offers, the other answers, and both send every ICE candidate they discover to the other as it arrives - waiting for them all is slower and gains nothing. Everything a peer will send has to be added before the offer that describes it: a data channel created afterwards means another offer and answer. Watch the states: signalling goes stable → have-local-offer → stable, and the connection goes new → connecting → connected.
var a = await webRtc.CreatePeerConnection(
iceServers: [new RtcIceServer { Urls = ["stun:stun.l.google.com:19302"] }],
onIceCandidate: json => SendToTheOtherPeer(json),
onConnectionState: state => InvokeAsync(StateHasChanged));
var channel = await a!.CreateDataChannel("chat"); // before the offer
var offer = await a.CreateOffer();
await a.SetLocalDescription(offer!);
// ... send offer.Sdp to the other peer, get an answer back ...
await a.SetRemoteDescription(answer);The reason to use a data channel rather than a WebSocket is what it can give up: an unordered, non-retransmitting channel delivers what is current rather than what is complete, which no TCP-based transport can offer at all. Nothing can be sent until the channel opens, so subscribe before the handshake - a channel that opens before anything is listening still opens, and that callback has already been missed.
{
// The peer connection from the section above. A data channel has to be created before the offer
// is made: it is part of what the offer describes.
private PeerConnectionHandle? a;
private RtcDataChannelHandle? channel;
private async Task Chat()
{
channel = await a!.CreateDataChannel("chat", ordered: false, maxRetransmits: 0);
channel!.Listen(
onMessage: m => InvokeAsync(StateHasChanged),
onOpen: () => { /* now it can send */ },
onClose: () => { });
await channel.SendText("hello, other browser");
await channel.SendBytes([1, 2, 3, 4]);
}
}Two real windows
CreatePeerConnection / CreateOffer / CreateAnswer / AddIceCandidate + BroadcastChannelThe sections above are a loopback: both peers live in this tab, so nothing is ever signalled. Here the two peers are in two different browser windows and the offer, the answer and every ICE candidate travel between them over BroadcastChannel - which reaches every same-origin window in this browser and is the smallest thing that can stand in for a signalling server. Open this page in a second window, press Host in one and Join in the other, and the chat below is a real peer connection. Two notes that bite every first WebRTC implementation are handled here: the joiner announces itself so the host makes its offer to somebody who is listening (an offer posted before the second window exists is simply lost), and candidates that arrive before the remote description is set are queued rather than added, because addIceCandidate rejects them until there is a description to attach them to.
Bit.Butil.WebRtc webRtc
Bit.Butil.BroadcastChannel broadcastChannel
{
private ButilSubscription? _signalling;
private PeerConnectionHandle? _peer;
private RtcDataChannelHandle? _channel;
private async Task Connect()
{
// Signalling: anything that reaches the other side will do - WebRTC does not specify it.
// Here, other windows of this browser.
_signalling = await broadcastChannel.Subscribe("butil-webrtc-demo", OnSignal);
_peer = await webRtc.CreatePeerConnection(
iceServers: [new RtcIceServer { Urls = ["stun:stun.l.google.com:19302"] }],
onIceCandidate: json => InvokeAsync(() => broadcastChannel.Post("butil-webrtc-demo",
new { type = "candidate", candidate = json }).AsTask()),
onRemoteChannel: channel => { _channel = channel; channel.Listen(onMessage: Show); });
}
// Host, once the other window says it is there:
private async Task Offer()
{
var offer = await _peer!.CreateOffer();
await _peer.SetLocalDescription(offer!);
await broadcastChannel.Post("butil-webrtc-demo", new { type = "offer", sdp = offer!.Sdp });
}
// Joiner, on that offer:
private async Task Answer(string sdp)
{
await _peer!.SetRemoteDescription(new RtcSessionDescription("offer", sdp, null));
var answer = await _peer.CreateAnswer();
await _peer.SetLocalDescription(answer!);
await broadcastChannel.Post("butil-webrtc-demo", new { type = "answer", sdp = answer!.Sdp });
}
}Everything the browser knows: bitrates, packet loss, round-trip time, the codec in use, which candidate pair won. The members differ per stat type, so each entry carries its type and a dictionary rather than pretending to a record shape. The two worth looking at first are the nominated candidate-pair - the path in use, and its round-trip time - and the inbound-rtp entries, which say what is actually arriving.
{
private PeerConnectionHandle? a; // a connected peer connection
private async Task Inspect()
{
var stats = await a!.GetStats();
// The nominated candidate pair is the route actually carrying traffic, out of everything ICE
// tried.
var path = stats.FirstOrDefault(s => s.Type == "candidate-pair" && s.Values.GetValueOrDefault("nominated") == "true");
var rtt = path?.Values.GetValueOrDefault("currentRoundTripTime");
}
}MediaDevices acquires the stream, AddTracksFrom puts it on the connection, and the other side shows it. Add the tracks before the offer: the offer describes what will be sent, so adding a camera afterwards means negotiating again - which is why this button reconnects. Needs a secure context and the camera permission, so it prompts.
Bit.Butil.MediaDevices mediaDevices
<video @ref="_remoteVideo" autoplay playsinline></video>
{
private ElementReference _remoteVideo;
private PeerConnectionHandle? a; // the sending side
private PeerConnectionHandle? b; // the receiving side
private async Task SendCamera()
{
var stream = await mediaDevices.GetUserMedia(audio: false, video: true);
// Before the offer: the tracks are part of what the offer describes, so adding them after it
// needs a second round of negotiation.
await a!.AddTracksFrom(stream!);
// on the other side:
await b!.AttachRemoteMedia(_remoteVideo);
}
}API reference
ValueTask<bool> IsSupported()ValueTask<PeerConnectionHandle?> CreatePeerConnection(RtcIceServer[]? iceServers = null, Action<string?>? onIceCandidate = null, Action<string>? onConnectionState = null, Action<string>? onTrack = null, Action<RtcDataChannelHandle>? onRemoteChannel = null)ValueTask<RtcDataChannelHandle?> CreateDataChannel(string label, bool ordered = true, int maxRetransmits = -1)ValueTask<RtcSessionDescription?> CreateOffer(), CreateAnswer()ValueTask<string?> SetLocalDescription(RtcSessionDescription), SetRemoteDescription(RtcSessionDescription)ValueTask<string?> AddIceCandidate(string? candidateJson)ValueTask<bool> AddTracksFrom(MediaStreamHandle stream), AttachRemoteMedia(ElementReference videoOrAudioElement)ValueTask<string> ...()ValueTask<RtcStat[]> GetStats()void Listen(Action<ButilMessage>? onMessage = null, Action? onOpen = null, Action? onClose = null)ValueTask<bool> SendText(string text), SendBytes(byte[] data)ValueTask<string> GetState(), ValueTask<long> GetBufferedAmount()class { string[] Urls; string? Username; string? Credential; }record (string Id, string Type, Dictionary<string, string> Values)