<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebRTC SFU Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"/>
<style>
:root {
--bg-gradient: radial-gradient(circle at 50% 50%, #1a1a2e 0%, #0d0d15 100%);
--card-bg: rgba(22, 22, 37, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--accent-blue: #3b82f6;
--accent-blue-glow: rgba(59, 130, 246, 0.5);
--accent-green: #10b981;
--accent-green-glow: rgba(16, 185, 129, 0.5);
--accent-orange: #f59e0b;
--accent-orange-glow: rgba(245, 158, 11, 0.5);
--accent-red: #ef4444;
--accent-red-glow: rgba(239, 68, 68, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background: var(--bg-gradient);
color: var(--text-primary);
min-height: 100vh;
padding: 2rem;
overflow-x: hidden;
}
.bg-glow,
.bg-glow-right {
position: fixed;
z-index: -1;
pointer-events: none;
filter: blur(80px);
}
.bg-glow {
width: 600px;
height: 600px;
top: -100px;
left: -100px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.15) 0%, rgba(59, 130, 246, 0.05) 50%, transparent 100%);
}
.bg-glow-right {
width: 500px;
height: 500px;
bottom: -100px;
right: -100px;
background: radial-gradient(circle, rgba(16, 185, 129, 0.1) 0%, rgba(59, 130, 246, 0.05) 60%, transparent 100%);
}
.container {
width: 100%;
max-width: 1100px;
margin: 0 auto;
position: relative;
z-index: 1;
}
header {
text-align: center;
margin-bottom: 2rem;
}
h1 {
font-size: 2.75rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, #a5b4fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.03em;
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.05rem;
font-weight: 300;
}
.card {
background: var(--card-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 1.25rem;
padding: 1.25rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, border-color 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.75rem;
}
.card-title {
font-size: 1.15rem;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-icon {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green-glow);
flex-shrink: 0;
}
.card-icon.waiting,
.card-icon.checking {
background-color: var(--accent-orange);
box-shadow: 0 0 10px var(--accent-orange-glow);
}
.card-icon.connected {
background-color: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green-glow);
}
.card-icon.failed {
background-color: var(--accent-red);
box-shadow: 0 0 10px var(--accent-red-glow);
}
.status-pill {
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-secondary);
}
.status-pill.waiting,
.status-pill.checking {
color: #fde68a;
background: rgba(245, 158, 11, 0.15);
border-color: rgba(245, 158, 11, 0.3);
}
.status-pill.connected {
color: #a7f3d0;
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.3);
}
.status-pill.failed {
color: #fca5a5;
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
}
.controls-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.controls-row label {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
}
input[type="number"] {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 0.6rem;
padding: 0.5rem 0.75rem;
width: 170px;
}
input[type="number"]:focus {
outline: none;
border-color: var(--accent-blue);
}
input[type="number"].invalid {
border-color: var(--accent-red);
}
button {
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: var(--accent-blue);
border: none;
border-radius: 0.6rem;
padding: 0.5rem 1.1rem;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease, opacity 0.2s ease;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toggle-field {
display: inline-flex;
align-items: center;
gap: 8px;
}
.toggle-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.switch, .switch * {
box-sizing: content-box;
}
.switch {
position: relative;
display: inline-block;
vertical-align: middle;
width: 56px;
height: 20px;
padding: 3px;
border-radius: 18px;
cursor: pointer;
background: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.3);
}
.switch:has(.switch-input:disabled) {
cursor: not-allowed;
}
.switch-input {
position: absolute;
top: 0;
left: 0;
opacity: 0;
margin: 0;
}
.switch-label {
position: relative;
display: block;
height: inherit;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
border-radius: inherit;
background: var(--accent-red);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: 0.15s ease-out;
transition-property: opacity, background;
}
.switch-label:before, .switch-label:after {
position: absolute;
top: 50%;
margin-top: -0.5em;
line-height: 1;
transition: inherit;
}
.switch-label:before {
content: attr(data-off);
right: 9px;
color: #fff;
}
.switch-label:after {
content: attr(data-on);
left: 9px;
color: #fff;
opacity: 0;
}
.switch-input:checked ~ .switch-label {
background: var(--accent-green);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
}
.switch-input:checked ~ .switch-label:before {
opacity: 0;
}
.switch-input:checked ~ .switch-label:after {
opacity: 1;
}
.switch-handle {
position: absolute;
top: 4px;
left: 4px;
width: 18px;
height: 18px;
border-radius: 10px;
background: #fff;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.4);
transition: left 0.15s ease-out;
}
.switch-input:checked ~ .switch-handle {
left: 40px;
}
.switch-input:disabled ~ .switch-label {
background: rgba(255, 255, 255, 0.12);
opacity: 0.45;
}
.switch-input:disabled ~ .switch-handle {
opacity: 0.6;
}
.chan-status {
margin-top: 0.85rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
}
#media {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.peer {
display: flex;
flex-direction: column;
}
.peer .card-header {
margin-bottom: 1rem;
}
.peer-video {
width: 100%;
aspect-ratio: 16 / 9;
background: #0d0d15;
border-radius: 0.75rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.peer-video video {
width: 100%;
height: 100%;
object-fit: contain;
}
.peer-audio {
width: 100%;
height: 44px;
margin-top: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.peer-audio audio {
width: 100%;
height: 32px;
}
.peer-video .placeholder,
.peer-audio .placeholder {
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
}
footer {
margin-top: 3rem;
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="bg-glow"></div>
<div class="bg-glow-right"></div>
<div class="container">
<header>
<h1>WebRTC SFU Chat</h1>
<p class="subtitle">Selective Forwarding Unit • multi-party audio / video</p>
</header>
<div class="card">
<div class="card-header">
<div class="card-title">
<span class="card-icon waiting" id="conn_icon"></span>
<div id="chan_status">Click Join Button...</div>
</div>
<span class="status-pill waiting" id="ice_status">Waiting</span>
</div>
<div class="controls-row">
<label for="room">Room</label>
<input type="number" id="room" min="0" max="18446744073709551615"/>
<button id="join" onClick="startRtc()">Join</button>
<button id="leave" onClick="leaveRtc()" disabled>Leave</button>
<span class="toggle-field">
<span class="toggle-name">Cam</span>
<label class="switch">
<input type="checkbox" id="cam" class="switch-input" disabled>
<span class="switch-label" data-on="On" data-off="Off"></span>
<span class="switch-handle"></span>
</label>
</span>
<span class="toggle-field">
<span class="toggle-name">Mic</span>
<label class="switch">
<input type="checkbox" id="mic" class="switch-input" disabled>
<span class="switch-label" data-on="On" data-off="Off"></span>
<span class="switch-handle"></span>
</label>
</span>
</div>
</div>
<div id="media"></div>
<footer>
<p>Built with WebRTC-rs • SFU chat demo</p>
</footer>
</div>
<script>
const byId = (id) => document.getElementById(id);
const byTag = (tag) => [].slice.call(document.getElementsByTagName(tag));
function roomIdToUuid(value) {
const hex = BigInt(value).toString(16).padStart(32, "0");
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) +
"-" + hex.slice(16, 20) + "-" + hex.slice(20);
}
byId("room").value = Math.floor(Math.random() * 1000000000);
const roomInput = byId("room");
const joinBtn = byId("join");
const chanStatus = byId("chan_status");
function validateRoomInput() {
const roomVal = roomInput.value;
const re = /^\d+$/;
let valid = re.test(roomVal);
if (valid) {
try {
const val = BigInt(roomVal);
valid = val >= 0n && val <= 18446744073709551615n;
} catch (e) {
valid = false;
}
}
if (valid) {
joinBtn.disabled = false;
roomInput.classList.remove('invalid');
if (chanStatus.innerText === "Room id must be a valid uint64 number!") {
chanStatus.innerText = "Click Join Button...";
chanStatus.style.color = "";
}
} else {
joinBtn.disabled = true;
roomInput.classList.add('invalid');
chanStatus.innerText = "Room id must be a valid uint64 number!";
chanStatus.style.color = "var(--accent-red)";
}
}
roomInput.addEventListener('input', validateRoomInput);
validateRoomInput();
let clientId = Math.floor(Math.random() * 1000000000);
let streamCam;
let streamMic;
let dataChannel;
let ws;
let rtc;
byId('cam').addEventListener('change', async () => {
if (!streamCam) {
if (byId('cam').checked) {
await startCam();
} else {
byId('cam').checked = false;
}
} else {
const track = streamCam.getVideoTracks()[0];
if (track) {
track.enabled = byId('cam').checked;
}
}
});
byId('mic').addEventListener('change', async () => {
if (!streamMic) {
if (byId('mic').checked) {
await startMic();
} else {
byId('mic').checked = false;
}
} else {
const track = streamMic.getAudioTracks()[0];
if (track) {
track.enabled = byId('mic').checked;
}
}
});
function newRtc() {
rtc = new RTCPeerConnection();
rtc.oniceconnectionstatechange = onIceStateChange;
rtc.ontrack = onTrack;
}
function sendSignal(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
const jsonStr = JSON.stringify(obj, (key, value) => {
if (typeof value === 'bigint') {
return `__BIGINT__:${value.toString()}__`;
}
return value;
}).replace(/"__BIGINT__:(\d+)__"/g, '$1');
ws.send(jsonStr);
} else {
console.log('WS not open; dropping', obj.cmd);
}
}
function setConnState(kind, label) {
byId('conn_icon').className = `card-icon ${kind}`;
const pill = byId('ice_status');
pill.className = `status-pill ${kind}`;
pill.textContent = label;
}
function onIceStateChange() {
if (!rtc) {
return;
}
const state = rtc.iceConnectionState;
if (state === 'connected' || state === 'completed') {
setConnState('connected', 'Connected');
} else if (state === 'checking' || state === 'new') {
setConnState('checking', 'Connecting');
} else if (state === 'failed' || state === 'disconnected') {
const label = state === 'failed' ? 'Failed' : 'Disconnected';
resetSession('failed', label, 'Disconnected from server — click Join to reconnect');
} else {
setConnState('waiting', 'Waiting');
}
}
async function setupCodecs() {
const videoCaps = RTCRtpSender.getCapabilities('video').codecs;
const vp8Only = videoCaps.filter(c => c.mimeType.toLowerCase() === 'video/vp8');
const audioCaps = RTCRtpSender.getCapabilities('audio').codecs;
const opusOnly = audioCaps.filter(c => c.mimeType.toLowerCase() === 'audio/opus');
rtc.getTransceivers().forEach(tcvr => {
if (tcvr.sender.track?.kind === 'video' && vp8Only.length > 0) {
tcvr.setCodecPreferences(vp8Only);
} else if (tcvr.sender.track?.kind === 'audio' && opusOnly.length > 0) {
tcvr.setCodecPreferences(opusOnly);
}
});
}
async function negotiate() {
await setupCodecs(); const offer = await rtc.createOffer();
console.log('do offer', offer.sdp.split('\r\n'));
rtc.setLocalDescription(offer);
sendSignal({cmd: 'offer', sdp: offer});
}
async function handleAnswer(answer) {
console.log('received answer', answer.sdp.split('\r\n'));
try {
rtc.setRemoteDescription(answer);
} catch (error) {
console.log('rtc.setRemoteDescription(answer) with error: ', error);
}
}
async function handleOffer(offer) {
console.log('handle offer', offer.sdp.split('\r\n'));
try {
await rtc.setRemoteDescription(offer);
} catch (error) {
console.log('rtc.setRemoteDescription(offer) with error: ', error);
}
const answer = await rtc.createAnswer();
console.log('offer response', answer.sdp.split('\r\n'));
await rtc.setLocalDescription(answer);
sendSignal({cmd: 'answer', sdp: answer, request_id: offer.request_id});
pruneStaleMedia();
}
function onWsMessage(event) {
const sdp = JSON.parse(event.data);
if (sdp.type === 'answer') {
handleAnswer(sdp);
} else if (sdp.type === 'offer') {
handleOffer(sdp);
} else {
console.log('unexpected WS message', event.data);
}
}
async function addCam() {
if (streamCam) {
return;
}
try {
streamCam = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 360 },
});
} catch (error) {
console.log('getUserMedia(video) failed', error);
byId('cam').checked = false;
return;
}
byId('cam').checked = true; rtc.addTransceiver(streamCam.getTracks()[0], {
direction: "sendonly",
streams: [streamCam],
});
}
async function addMic() {
if (streamMic) {
return;
}
try {
streamMic = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
console.log('getUserMedia(audio) failed', error);
byId('mic').checked = false;
return;
}
byId('mic').checked = true; rtc.addTransceiver(streamMic.getTracks()[0], {
streams: [streamMic],
direction: "sendonly",
});
}
async function startCam() {
await addCam();
await negotiate();
}
async function startMic() {
await addMic();
await negotiate();
}
function publisherClientId(e) {
const ids = [];
if (e.streams && e.streams[0]) ids.push(e.streams[0].id);
if (e.track) ids.push(e.track.id);
for (const id of ids) {
const m = /peer-(\d+)/.exec(id || '');
if (m) return m[1];
}
return null;
}
function makePlaceholder(text) {
const span = document.createElement('span');
span.className = 'placeholder';
span.textContent = text;
return span;
}
function getOrCreatePeerBox(peerKey, publisher) {
const boxId = `peer-box-${peerKey}`;
let box = byId(boxId);
if (box) {
return box;
}
box = document.createElement('div');
box.className = 'peer card';
box.id = boxId;
const header = document.createElement('div');
header.className = 'card-header';
const title = document.createElement('div');
title.className = 'card-title';
const icon = document.createElement('span');
icon.className = 'card-icon';
const caption = document.createElement('span');
caption.className = 'peer-caption';
caption.textContent = `${publisher ?? 'unknown'}`;
title.appendChild(icon);
title.appendChild(caption);
header.appendChild(title);
const video = document.createElement('div');
video.className = 'peer-video';
video.appendChild(makePlaceholder('No video'));
const audio = document.createElement('div');
audio.className = 'peer-audio';
audio.appendChild(makePlaceholder('No audio'));
box.appendChild(header);
box.appendChild(video);
box.appendChild(audio);
byId('media').appendChild(box);
return box;
}
function onTrack(e) {
console.log('ontrack', e.track, e.streams);
const track = e.track;
const domId = `media-${track.id}`;
if (byId(domId)) {
return;
}
const publisher = publisherClientId(e);
const peerKey = publisher ?? ((e.streams && e.streams[0] && e.streams[0].id) || track.id);
const box = getOrCreatePeerBox(peerKey, publisher);
const kind = track.kind === 'audio' ? 'audio' : 'video';
const el = document.createElement(kind);
el.id = domId;
el.controls = true;
el.autoplay = true;
el.playsInline = true;
el.hidden = track.muted;
const media = new MediaStream();
media.addTrack(track);
el.srcObject = media;
box.querySelector(kind === 'audio' ? '.peer-audio' : '.peer-video').replaceChildren(el);
const playMedia = () => {
el.play().catch((error) => {
console.log('media.play() failed', error);
});
};
el.addEventListener('loadedmetadata', playMedia);
playMedia();
track.addEventListener('mute', () => {
console.log('track muted', track);
el.hidden = true;
});
track.addEventListener('unmute', () => {
console.log('track unmuted', track);
el.hidden = false;
playMedia();
});
}
function pruneStaleMedia() {
const live = new Set();
rtc.getTransceivers().forEach((t) => {
const track = t.receiver && t.receiver.track;
if (track && (t.currentDirection === 'recvonly' || t.currentDirection === 'sendrecv')) {
live.add(track.id);
}
});
byId('media').querySelectorAll('video, audio').forEach((el) => {
const trackId = el.id.replace(/^media-/, '');
if (!live.has(trackId)) {
el.remove();
}
});
byId('media').querySelectorAll('.peer').forEach((box) => {
if (!box.querySelector('video, audio')) {
box.remove();
return;
}
if (!box.querySelector('.peer-video video')) {
box.querySelector('.peer-video').replaceChildren(makePlaceholder('No video'));
}
if (!box.querySelector('.peer-audio audio')) {
box.querySelector('.peer-audio').replaceChildren(makePlaceholder('No audio'));
}
});
}
async function startRtc() {
newRtc();
setConnState('checking', 'Connecting');
byId('chan_status').innerText = 'Joining room ' + byId("room").value + ' as client ' + clientId;
byId("room").disabled = true;
byId('join').disabled = true;
byId('leave').disabled = false;
await addCam();
await addMic();
dataChannel = rtc.createDataChannel("bootstrap");
dataChannel.onopen = () => {
byId('chan_status').innerText = 'Joined room ' + byId("room").value + ' as client ' + clientId;
byId('cam').disabled = false;
byId('mic').disabled = false;
};
ws = new WebSocket('wss://' + location.host + '/ws');
ws.onopen = async () => {
console.log('WS open; registering ' + byId("room").value + '/' + clientId);
sendSignal({cmd: 'register', roomid: roomIdToUuid(byId("room").value), clientid: clientId});
await setupCodecs(); const offer = await rtc.createOffer();
rtc.setLocalDescription(offer);
console.log('send offer', offer.sdp.split('\r\n'));
sendSignal({cmd: 'offer', sdp: offer});
};
ws.onmessage = onWsMessage;
ws.onclose = (e) => console.log('WS closed:', e.code, e.reason);
ws.onerror = (e) => console.log('WS error:', e);
}
async function leaveRtc() {
sendSignal({cmd: 'leave'});
resetSession('waiting', 'Waiting', 'Click Join Button...');
}
function resetSession(connKind, connLabel, chanText) {
if (ws) {
ws.close();
ws = null;
}
const oldRtc = rtc;
rtc = null;
oldRtc?.close();
dataChannel = null;
streamCam?.getTracks().forEach((t) => t.stop());
streamMic?.getTracks().forEach((t) => t.stop());
streamCam = undefined;
streamMic = undefined;
byId('media').innerHTML = '';
setConnState(connKind, connLabel);
byId('chan_status').innerText = chanText;
byId('chan_status').style.color = '';
byId("room").disabled = false;
byId('join').disabled = false;
byId('leave').disabled = true;
byId('cam').disabled = true;
byId('cam').checked = false;
byId('mic').disabled = true;
byId('mic').checked = false;
}
</script>
</body>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-BHTZSJEX72"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-BHTZSJEX72');
</script>
</html>