120 lines
4.1 KiB
HTML
120 lines
4.1 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>WHEP Viewer</title>
|
|
<style>
|
|
body { margin: 0; background: #111; color: #eee; font-family: monospace; }
|
|
#video { display: block; width: 100%; max-width: 800px; }
|
|
#stats {
|
|
max-width: 800px;
|
|
padding: 8px 12px;
|
|
background: rgba(0,0,0,0.6);
|
|
font-size: 13px;
|
|
line-height: 1.8;
|
|
}
|
|
.label { color: #aaa; }
|
|
.value { color: #7ef; font-weight: bold; }
|
|
.warn { color: #fa0; }
|
|
.bad { color: #f44; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<video id="video" autoplay muted playsinline></video>
|
|
<div id="stats">Waiting for stream…</div>
|
|
<script>
|
|
const fmt = ms => ms < 0 ? '—' : ms.toFixed(1) + ' ms';
|
|
|
|
const colorClass = ms => ms < 0 ? '' : ms < 80 ? 'value' : ms < 200 ? 'warn' : 'bad';
|
|
|
|
let prevStats = null;
|
|
|
|
async function pollStats(pc) {
|
|
const reports = await pc.getStats();
|
|
|
|
let inbound = null;
|
|
let networkRttMs = -1;
|
|
reports.forEach(r => {
|
|
if (r.type === 'inbound-rtp' && r.kind === 'video') inbound = r;
|
|
// The nominated ICE candidate pair carries the active STUN ping RTT.
|
|
if (r.type === 'candidate-pair' && r.nominated && r.currentRoundTripTime != null) {
|
|
networkRttMs = r.currentRoundTripTime * 1000;
|
|
}
|
|
});
|
|
|
|
if (!inbound) return;
|
|
|
|
// Network one-way: ICE STUN ping RTT / 2.
|
|
const networkOneWayMs = networkRttMs >= 0 ? networkRttMs / 2 : -1;
|
|
|
|
// Jitter buffer latency: average time a packet waits before being emitted to decoder.
|
|
const jitterMs = inbound.jitterBufferEmittedCount > 0
|
|
? (inbound.jitterBufferDelay / inbound.jitterBufferEmittedCount) * 1000
|
|
: -1;
|
|
|
|
// Decode latency: average time spent decoding each frame.
|
|
const decodeMs = inbound.framesDecoded > 0
|
|
? (inbound.totalDecodeTime / inbound.framesDecoded) * 1000
|
|
: -1;
|
|
|
|
// Server → client: network transit + jitter buffer + decode.
|
|
const serverToClientMs =
|
|
(networkOneWayMs >= 0 && jitterMs >= 0 && decodeMs >= 0)
|
|
? networkOneWayMs + jitterMs + decodeMs
|
|
: -1;
|
|
|
|
// Frames per second (received from network, before decode).
|
|
const fps = inbound.framesPerSecond ?? -1;
|
|
|
|
// Packets lost ratio.
|
|
const totalPkts = (inbound.packetsReceived || 0) + (inbound.packetsLost || 0);
|
|
const lossRatio = totalPkts > 0
|
|
? ((inbound.packetsLost || 0) / totalPkts * 100).toFixed(1) + '%'
|
|
: '—';
|
|
|
|
const el = document.getElementById('stats');
|
|
const row = (label, val, cls) =>
|
|
`<span class="label">${label}:</span> <span class="${cls}">${val}</span>`;
|
|
|
|
el.innerHTML = [
|
|
row('Server → Client', fmt(serverToClientMs), colorClass(serverToClientMs)),
|
|
row(' Network (1-way)', fmt(networkOneWayMs), colorClass(networkOneWayMs)),
|
|
row(' Jitter buffer', fmt(jitterMs), colorClass(jitterMs)),
|
|
row(' Decode', fmt(decodeMs), colorClass(decodeMs)),
|
|
row('FPS', fps >= 0 ? fps.toFixed(1) : '—', 'value'),
|
|
row('Packet loss', lossRatio, 'value'),
|
|
row('Jitter', fmt(inbound.jitter * 1000), colorClass(inbound.jitter * 1000)),
|
|
].join('<br>');
|
|
|
|
prevStats = inbound;
|
|
}
|
|
|
|
const start = async () => {
|
|
window.pc = new RTCPeerConnection();
|
|
const pc = window.pc;
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.ontrack = (e) => {
|
|
const video = document.getElementById('video');
|
|
video.srcObject = new MediaStream([e.track]);
|
|
video.play().catch(err => console.error('play() failed:', err));
|
|
// Start polling once we have a track.
|
|
setInterval(() => pollStats(pc), 500);
|
|
};
|
|
pc.oniceconnectionstatechange = () => console.log('ice state:', pc.iceConnectionState);
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
const res = await fetch('http://localhost:5000/whep/test', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/sdp' },
|
|
body: offer.sdp,
|
|
});
|
|
const answer = await res.text();
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
|
};
|
|
|
|
start();
|
|
</script>
|
|
</body>
|
|
</html>
|