<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Sib WebRTC Sample</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
background: #0b0e14;
color: #e6e6e6;
}
header {
padding: 12px 14px;
border-bottom: 1px solid #222a3a;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
button {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #2a3550;
background: #121a2a;
color: #e6e6e6;
cursor: pointer;
}
button:disabled {
opacity: .5;
cursor: not-allowed;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.pill {
padding: 6px 10px;
border: 1px solid #2a3550;
border-radius: 999px;
background: #0f1524;
font-size: 12px;
}
main {
display: grid;
grid-template-columns: 1fr 360px;
gap: 12px;
padding: 12px;
}
@media (max-width: 980px) {
main {
grid-template-columns: 1fr;
}
}
.card {
border: 1px solid #222a3a;
background: #0f1524;
border-radius: 16px;
overflow: hidden;
}
.card h3 {
margin: 0;
padding: 10px 12px;
border-bottom: 1px solid #222a3a;
font-size: 14px;
color: #cbd5ff;
}
.card .content {
padding: 12px;
}
video {
width: 100%;
height: auto;
background: #000;
display: block;
}
input {
width: 100%;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid #2a3550;
background: #0b1222;
color: #e6e6e6;
}
label {
font-size: 12px;
opacity: .9;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.log {
height: 220px;
overflow: auto;
background: #0b1222;
border: 1px solid #2a3550;
border-radius: 12px;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono";
font-size: 12px;
line-height: 1.35;
}
.muted {
opacity: .75;
font-size: 12px;
}
</style>
</head>
<body>
<header>
<div class="row">
<button id="btnStart">Start</button>
<button id="btnStop" disabled>Stop</button>
<span class="pill" id="wsState">WS: closed</span>
<span class="pill" id="pcState">PC: idle</span>
<span class="pill" id="iceState">ICE: idle</span>
</div>
<div class="row muted">
Tip: browsers often require a click to autoplay audio. Click <b>Start</b>.
</div>
</header>
<main>
<div class="card">
<h3>Stream</h3>
<div class="content">
<video id="video" playsinline autoplay controls></video>
<div class="muted" style="margin-top:10px;">
If you see video but no sound: ensure the server is capturing audio correctly, and your browser
allows audio playback.
</div>
</div>
</div>
<div class="card">
<h3>Controls & Logs</h3>
<div class="content">
<div class="grid">
<div>
<label>Width</label>
<input id="w" type="number" min="320" step="10" value="1280" />
</div>
<div>
<label>Height</label>
<input id="h" type="number" min="240" step="10" value="720" />
</div>
<div>
<label>FPS</label>
<input id="fps" type="number" min="5" step="1" value="60" />
</div>
<div>
<label>Bitrate (kbps)</label>
<input id="br" type="number" min="200" step="100" value="6000" />
</div>
</div>
<div class="row" style="margin-top:10px;">
<button id="btnApply" disabled>Apply CTRL</button>
<span class="pill" id="stats">stats: -</span>
</div>
<div style="margin-top:10px;">
<div class="log" id="log"></div>
</div>
</div>
</div>
</main>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const btnStart = $("btnStart");
const btnStop = $("btnStop");
const btnApply = $("btnApply");
const elWsState = $("wsState");
const elPcState = $("pcState");
const elIceState = $("iceState");
const elStats = $("stats");
const elLog = $("log");
const videoEl = $("video");
const inpW = $("w");
const inpH = $("h");
const inpFps = $("fps");
const inpBr = $("br");
let ws = null;
let pc = null;
let statsTimer = null;
function log(...args) {
const s = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
const line = `[${new Date().toLocaleTimeString()}] ${s}`;
elLog.textContent += line + "\n";
elLog.scrollTop = elLog.scrollHeight;
}
function wsUrl() {
const proto = (location.protocol === "https:") ? "wss" : "ws";
return `${proto}://${location.host}${location.pathname}`;
}
function setUiConnected(connected) {
btnStart.disabled = connected;
btnStop.disabled = !connected;
btnApply.disabled = !connected;
}
function send(msg) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
}
function closeAll() {
if (statsTimer) { clearInterval(statsTimer); statsTimer = null; }
if (pc) {
try { pc.ontrack = null; pc.onicecandidate = null; pc.onconnectionstatechange = null; pc.oniceconnectionstatechange = null; pc.close(); } catch { }
pc = null;
}
if (ws) {
try { ws.close(); } catch { }
ws = null;
}
try { videoEl.srcObject = null; } catch { }
elWsState.textContent = "WS: closed";
elPcState.textContent = "PC: idle";
elIceState.textContent = "ICE: idle";
elStats.textContent = "stats: -";
setUiConnected(false);
}
async function start() {
closeAll();
log("connecting WS:", wsUrl());
ws = new WebSocket(wsUrl());
ws.onopen = async () => {
elWsState.textContent = "WS: open";
setUiConnected(true);
log("WS open");
pc = new RTCPeerConnection({
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
});
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
pc.onconnectionstatechange = () => {
elPcState.textContent = "PC: " + pc.connectionState;
log("PC state:", pc.connectionState);
};
pc.oniceconnectionstatechange = () => {
elIceState.textContent = "ICE: " + pc.iceConnectionState;
log("ICE state:", pc.iceConnectionState);
};
pc.onicecandidate = (ev) => {
if (!ev.candidate) return;
send({
type: "Ice", data: {
candidate: ev.candidate.candidate,
sdpMid: ev.candidate.sdpMid,
sdpMLineIndex: ev.candidate.sdpMLineIndex,
usernameFragment: ev.candidate.usernameFragment,
}
});
};
const ms = new MediaStream();
pc.ontrack = (ev) => {
log("ontrack:", ev.track.kind, ev.track.id);
ms.addTrack(ev.track);
videoEl.srcObject = ms;
videoEl.play().catch(() => { });
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
send({ type: "Offer", data: offer.sdp });
log("sent Offer");
statsTimer = setInterval(async () => {
if (!pc) return;
try {
const report = await pc.getStats();
let rtt = null, jitter = null, loss = null, fps = null, inBps = null;
report.forEach((s) => {
if (s.type === "inbound-rtp") {
if (typeof s.jitter === "number") jitter = s.jitter * 1000.0;
if (typeof s.packetsLost === "number" && typeof s.packetsReceived === "number") {
const total = s.packetsLost + s.packetsReceived;
if (total > 0) loss = (s.packetsLost / total) * 100.0;
}
if (s.kind === "video") {
if (typeof s.framesPerSecond === "number") fps = s.framesPerSecond;
if (typeof s.bytesReceived === "number") {
}
}
}
if (s.type === "candidate-pair" && s.state === "succeeded") {
if (typeof s.currentRoundTripTime === "number") rtt = s.currentRoundTripTime * 1000.0;
if (typeof s.availableIncomingBitrate === "number") inBps = s.availableIncomingBitrate;
}
});
elStats.textContent = `stats: rtt=${rtt?.toFixed?.(1) ?? "-"}ms jitter=${jitter?.toFixed?.(1) ?? "-"}ms loss=${loss?.toFixed?.(2) ?? "-"}% fps=${fps?.toFixed?.(1) ?? "-"}`;
send({
type: "ClientStats", data: {
rtt_ms: rtt,
jitter_ms: jitter,
loss: loss,
fps: fps,
available_in_bps: inBps,
}
});
} catch (e) {
}
}, 1000);
};
ws.onmessage = async (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (!msg || !msg.type) return;
if (msg.type === "Info") {
log("INFO:", msg.data);
return;
}
if (msg.type === "Error") {
log("ERROR:", msg.data);
return;
}
if (msg.type === "Answer") {
log("got Answer");
if (!pc) return;
await pc.setRemoteDescription({ type: "answer", sdp: msg.data });
return;
}
if (msg.type === "Ice") {
if (!pc) return;
try {
await pc.addIceCandidate({
candidate: msg.data.candidate,
sdpMid: msg.data.sdpMid ?? null,
sdpMLineIndex: (msg.data.sdpMLineIndex ?? null),
usernameFragment: msg.data.usernameFragment ?? null,
});
} catch (e) {
log("addIceCandidate failed:", String(e));
}
return;
}
if (msg.type === "ServerStats") {
log("server stats:", msg.data);
return;
}
};
ws.onclose = () => {
log("WS closed");
closeAll();
};
ws.onerror = () => {
log("WS error");
};
}
function applyCtrl() {
const width = parseInt(inpW.value || "1280", 10);
const height = parseInt(inpH.value || "720", 10);
const fps = parseInt(inpFps.value || "60", 10);
const bitrate_kbps = parseInt(inpBr.value || "6000", 10);
send({ type: "Ctrl", data: { width, height, fps, bitrate_kbps } });
log("sent Ctrl:", { width, height, fps, bitrate_kbps });
}
btnStart.addEventListener("click", () => start().catch(e => log("start failed:", String(e))));
btnStop.addEventListener("click", () => closeAll());
btnApply.addEventListener("click", () => applyCtrl());
window.addEventListener("beforeunload", () => closeAll());
})();
</script>
</body>
</html>