From a1c9798215741270412b0559fde0cd02b9e8fa95 Mon Sep 17 00:00:00 2001
From: Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com>
Date: Wed, 10 Dec 2025 21:15:04 -0500
Subject: [PATCH] highest quality
---
package.json | 2 +-
public/client.js | 681 ++++++++++++++++++++++++++++++++------------
public/index.html | 520 ++++++++++++++++++---------------
public/monitor.html | 439 ++++++++++++++++------------
public/monitor.js | 257 ++++++++++-------
server.js | 324 +++++++++++----------
6 files changed, 1351 insertions(+), 872 deletions(-)
diff --git a/package.json b/package.json
index 078f8dd..5cf8a68 100644
--- a/package.json
+++ b/package.json
@@ -15,4 +15,4 @@
"express": "^5.2.1",
"socket.io": "^4.8.1"
}
-}
+}
\ No newline at end of file
diff --git a/public/client.js b/public/client.js
index 434e016..4965d04 100644
--- a/public/client.js
+++ b/public/client.js
@@ -6,6 +6,8 @@ const streamListUl = document.getElementById('streamList');
const lobbyView = document.getElementById('lobby-view');
const streamView = document.getElementById('stream-view');
const streamTitle = document.getElementById('currentStreamTitle');
+const upstreamMetricsBox = document.getElementById('upstreamMetrics');
+const downstreamMetricsBox = document.getElementById('downstreamMetrics');
// --- Global State ---
let localStream = null;
@@ -15,20 +17,39 @@ let iceServers = [];
let currentUpstreamPC = null;
let currentDownstreamPC = null;
let downstreamId = null;
+let origVideoStream,
+ displayedVideoStream,
+ sentVideoStream,
+ origAudioStream,
+ displayedAudioStream,
+ sentAudioStream,
+ origVideoTrack,
+ origAudioTrack,
+ displayedVideoTrack,
+ displayedAudioTrack,
+ sentVideoTrack,
+ sentAudioTrack;
// FADING connections
let oldUpstreamPCs = [];
let oldDownstreamPCs = [];
+// Debug metrics history for bitrate/fps deltas
+const metricsHistory = {
+ upstream: null,
+ downstream: null,
+};
+
// --- 1. Initialization ---
async function init() {
- try {
- const response = await fetch('/api/get-turn-credentials');
- const data = await response.json();
- iceServers = data.iceServers;
- } catch (e) {
- iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
- }
+ try {
+ const response = await fetch('/api/get-turn-credentials');
+ const data = await response.json();
+ console.log('TURN data:', data);
+ iceServers = data.iceServers;
+ } catch (e) {
+ iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
+ }
}
init();
@@ -36,255 +57,541 @@ init();
// Receive list of streams from server
socket.on('stream_list_update', (streams) => {
- streamListUl.innerHTML = "";
- if (streams.length === 0) {
- streamListUl.innerHTML = "
No active streams. Start one!";
- return;
- }
+ streamListUl.innerHTML = '';
+ if (streams.length === 0) {
+ streamListUl.innerHTML = "No active streams. Start one!";
+ return;
+ }
- streams.forEach(name => {
- const li = document.createElement('li');
- li.innerText = `${name}`;
- li.onclick = () => joinStream(name);
- streamListUl.appendChild(li);
- });
+ streams.forEach((name) => {
+ const li = document.createElement('li');
+ li.innerText = `${name}`;
+ li.onclick = () => joinStream(name);
+ streamListUl.appendChild(li);
+ });
});
async function startStream() {
- const name = document.getElementById('streamNameInput').value;
- const fileInput = document.getElementById('videoFileInput');
- const file = fileInput.files[0];
+ const name = document.getElementById('streamNameInput').value;
+ const fileInput = document.getElementById('videoFileInput');
+ const file = fileInput.files[0];
- if (!name) return alert("Please enter a name");
+ if (!name) return alert('Please enter a name');
- try {
- if (file) {
- // --- OPTION A: VIDEO FILE MODE ---
- console.log("Starting stream from video file...");
+ try {
+ if (file) {
+ // --- OPTION A: VIDEO FILE MODE ---
+ console.log('Starting stream from video file...');
- // 1. Create a URL for the local file
- const fileURL = URL.createObjectURL(file);
+ // 1. Create a URL for the local file
+ const fileURL = URL.createObjectURL(file);
- // 2. Set it to the local video element
- localVideo.src = fileURL;
- localVideo.loop = true; // <--- HERE IS THE LOOP LOGIC
- localVideo.muted = false; // Must be unmuted to capture audio, use headphones!
- localVideo.volume = 1.0;
+ // 2. Set it to the local video element
+ localVideo.src = fileURL;
+ localVideo.loop = true; // <--- HERE IS THE LOOP LOGIC
+ localVideo.muted = false; // Must be unmuted to capture audio, use headphones!
+ localVideo.volume = 1.0;
- // 3. Play the video (required before capturing)
- await localVideo.play();
+ // 3. Play the video (required before capturing)
+ await localVideo.play();
- // 4. Capture the stream from the video element
- // (Chrome/Edge use captureStream, Firefox uses mozCaptureStream)
- if (localVideo.captureStream) {
- localStream = localVideo.captureStream();
- } else if (localVideo.mozCaptureStream) {
- localStream = localVideo.mozCaptureStream();
- } else {
- return alert("Your browser does not support capturing video from files.");
- }
+ // 4. Capture the stream from the video element
+ // (Chrome/Edge use captureStream, Firefox uses mozCaptureStream)
+ if (localVideo.captureStream) {
+ localStream = localVideo.captureStream();
+ } else {
+ return alert('Your browser does not support capturing video from files.');
+ }
- // Note: captureStream() sometimes doesn't capture audio if the element is muted.
- // If you want to stream audio, you must hear it locally too.
+ // Note: captureStream() sometimes doesn't capture audio if the element is muted.
+ // If you want to stream audio, you must hear it locally too.
+ } else {
+ // --- OPTION B: WEBCAM MODE ---
+ console.log('Starting stream from Webcam...');
+ localStream = await navigator.mediaDevices.getUserMedia({
+ video: { width: { ideal: 4096 } },
+ audio: true,
+ });
+ localVideo.srcObject = localStream.clone();
+ localVideo.muted = true; // Mute local webcam to avoid feedback loop
+ }
- } else {
- // --- OPTION B: WEBCAM MODE ---
- console.log("Starting stream from Webcam...");
- localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
- localVideo.srcObject = localStream;
- localVideo.muted = true; // Mute local webcam to avoid feedback loop
- }
+ // --- COMMON LOGIC ---
- // --- COMMON LOGIC ---
+ // UI Switch
+ lobbyView.style.display = 'none';
+ streamView.style.display = 'block';
+ remoteVideo.style.display = 'none'; // Broadcaster doesn't see remote
+ streamTitle.innerText = `Broadcasting: ${name}`;
- // UI Switch
- lobbyView.style.display = 'none';
- streamView.style.display = 'block';
- remoteVideo.style.display = 'none'; // Broadcaster doesn't see remote
- streamTitle.innerText = `Broadcasting: ${name}`;
-
- socket.emit('start_stream', name);
- statusDiv.innerText = `Status: Broadcasting (Head) | ID: ${socket.id}`;
-
- } catch (err) {
- console.error(err);
- alert("Failed to start stream: " + err.message);
- }
+ socket.emit('start_stream', name);
+ statusDiv.innerText = `Status: Broadcasting (Head) | ID: ${socket.id}`;
+ } catch (err) {
+ console.error(err);
+ alert('Failed to start stream: ' + err.message);
+ }
}
function joinStream(name) {
- // UI Switch
- lobbyView.style.display = 'none';
- streamView.style.display = 'block';
- localVideo.style.display = 'none'; // Viewers don't see themselves
- streamTitle.innerText = `Watching: ${name}`;
+ // UI Switch
+ lobbyView.style.display = 'none';
+ streamView.style.display = 'block';
+ localVideo.style.display = 'none'; // Viewers don't see themselves
+ streamTitle.innerText = `Watching: ${name}`;
- socket.emit('join_stream', name);
- statusDiv.innerText = `Status: Joining chain... | ID: ${socket.id}`;
+ socket.emit('join_stream', name);
+ statusDiv.innerText = `Status: Joining chain... | ID: ${socket.id}`;
}
function leaveStream() {
- location.reload(); // Simple way to reset state and go back to lobby
+ location.reload(); // Simple way to reset state and go back to lobby
}
-
// --- 3. Health Reporting (Unchanged) ---
setInterval(calculateAndReportHealth, 5000);
+setInterval(pollPeerMetrics, 2000);
async function calculateAndReportHealth() {
- if (localStream) {
- socket.emit('update_score', 100);
- return;
- }
- if (!currentUpstreamPC) return;
+ if (localStream) {
+ socket.emit('update_score', 100);
+ return;
+ }
+ if (!currentUpstreamPC) return;
- try {
- const stats = await currentUpstreamPC.getStats();
- let packetsLost = 0;
- let packetsReceived = 0;
- let jitter = 0;
+ try {
+ const stats = await currentUpstreamPC.getStats();
+ let packetsLost = 0;
+ let packetsReceived = 0;
+ let jitter = 0;
- stats.forEach(report => {
- if (report.type === 'inbound-rtp' && report.kind === 'video') {
- packetsLost = report.packetsLost || 0;
- packetsReceived = report.packetsReceived || 0;
- jitter = report.jitter || 0;
- }
- });
+ stats.forEach((report) => {
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
+ packetsLost = report.packetsLost || 0;
+ packetsReceived = report.packetsReceived || 0;
+ jitter = report.jitter || 0;
+ }
+ });
- const totalPackets = packetsReceived + packetsLost;
- if (totalPackets === 0) return;
+ const totalPackets = packetsReceived + packetsLost;
+ if (totalPackets === 0) return;
- const lossRate = packetsLost / totalPackets;
- const score = 100 - (lossRate * 100 * 5) - (jitter * 100);
- const finalScore = Math.max(0, Math.min(100, score));
- console.log(`Health: ${finalScore.toFixed(0)} | Loss: ${(lossRate * 100).toFixed(2)}%`);
+ const lossRate = packetsLost / totalPackets;
+ const score = 100 - lossRate * 100 * 5 - jitter * 100;
+ const finalScore = Math.max(0, Math.min(100, score));
+ console.log(`Health: ${finalScore.toFixed(0)} | Loss: ${(lossRate * 100).toFixed(2)}%`);
- socket.emit('update_score', finalScore);
- } catch (e) { }
+ socket.emit('update_score', finalScore);
+ } catch (e) {}
}
// --- 4. Socket Events (Logic Updated for Smart Disconnect) ---
socket.on('connect_to_downstream', async ({ downstreamId: targetId }) => {
- downstreamId = targetId;
- await setupDownstreamConnection(targetId);
+ downstreamId = targetId;
+ await setupDownstreamConnection(targetId);
});
socket.on('disconnect_downstream', () => {
- if (currentDownstreamPC) {
- currentDownstreamPC.close();
- currentDownstreamPC = null;
- downstreamId = null;
- }
- oldDownstreamPCs.forEach(pc => pc.close());
- oldDownstreamPCs = [];
+ if (currentDownstreamPC) {
+ currentDownstreamPC.close();
+ currentDownstreamPC = null;
+ downstreamId = null;
+ }
+ oldDownstreamPCs.forEach((pc) => pc.close());
+ oldDownstreamPCs = [];
});
socket.on('signal_msg', async ({ sender, type, sdp, candidate }) => {
- if (type === 'offer') {
- await handleUpstreamOffer(sender, sdp);
- } else if (type === 'answer') {
- if (currentDownstreamPC && sender === downstreamId) {
- await currentDownstreamPC.setRemoteDescription(new RTCSessionDescription(sdp));
- }
- } else if (type === 'candidate') {
- const ice = new RTCIceCandidate(candidate);
- if (currentDownstreamPC && sender === downstreamId) currentDownstreamPC.addIceCandidate(ice).catch(e => { });
- if (currentUpstreamPC) currentUpstreamPC.addIceCandidate(ice).catch(e => { });
- oldUpstreamPCs.forEach(pc => pc.addIceCandidate(ice).catch(e => { }));
- oldDownstreamPCs.forEach(pc => pc.addIceCandidate(ice).catch(e => { }));
- }
+ if (type === 'offer') {
+ await handleUpstreamOffer(sender, sdp);
+ } else if (type === 'answer') {
+ if (currentDownstreamPC && sender === downstreamId) {
+ await currentDownstreamPC.setRemoteDescription(new RTCSessionDescription(sdp));
+ }
+ } else if (type === 'candidate') {
+ const ice = new RTCIceCandidate(candidate);
+ if (currentDownstreamPC && sender === downstreamId) currentDownstreamPC.addIceCandidate(ice).catch((e) => {});
+ if (currentUpstreamPC) currentUpstreamPC.addIceCandidate(ice).catch((e) => {});
+ oldUpstreamPCs.forEach((pc) => pc.addIceCandidate(ice).catch((e) => {}));
+ oldDownstreamPCs.forEach((pc) => pc.addIceCandidate(ice).catch((e) => {}));
+ }
});
socket.on('error_msg', (msg) => {
- alert(msg);
- location.reload();
+ alert(msg);
+ location.reload();
});
socket.on('stream_ended', () => {
- alert("Stream ended by host");
- location.reload();
+ alert('Stream ended by host');
+ location.reload();
});
socket.on('request_keyframe', () => {
- console.log("Network requested keyframe");
- // If we were using Insertable streams, we'd need to handle this.
- // With Standard API, the browser handles PLI automatically.
+ console.log('Network requested keyframe');
+ // If we were using Insertable streams, we'd need to handle this.
+ // With Standard API, the browser handles PLI automatically.
});
// --- 5. WebRTC Logic (Merged Smart Disconnect) ---
async function handleUpstreamOffer(senderId, sdp) {
- const newPC = new RTCPeerConnection({ iceServers });
+ const newPC = new RTCPeerConnection({ iceServers });
- // Safety: If connection hangs, kill old one eventually
- let safetyTimer = setTimeout(() => {
- if (currentUpstreamPC && currentUpstreamPC !== newPC) {
- currentUpstreamPC.close();
- }
- }, 15000);
+ // Safety: If connection hangs, kill old one eventually
+ let safetyTimer = setTimeout(() => {
+ if (currentUpstreamPC && currentUpstreamPC !== newPC) {
+ currentUpstreamPC.close();
+ }
+ }, 15000);
- newPC.ontrack = (event) => {
- clearTimeout(safetyTimer); // Success!
+ newPC.ontrack = async (event) => {
+ console.log('Received track from upstream:', event);
+ clearTimeout(safetyTimer); // Success!
- remoteVideo.srcObject = event.streams[0];
- statusDiv.innerText = `Status: Connected | ID: ${socket.id}`;
+ if (event.track.kind === 'video') {
+ origVideoStream = event.streams[0];
+ displayedVideoStream = origVideoStream.clone();
+ sentVideoStream = origVideoStream.clone();
- if (currentDownstreamPC) {
- const sender = currentDownstreamPC.getSenders().find(s => s.track && s.track.kind === event.track.kind);
- if (sender) sender.replaceTrack(event.track);
- }
+ origVideoTrack = event.track;
+ displayedVideoTrack = origVideoTrack.clone();
+ sentVideoTrack = origVideoTrack.clone();
+ } else if (event.track.kind === 'audio') {
+ origAudioStream = event.streams[0];
+ displayedAudioStream = origAudioStream.clone();
+ sentAudioStream = origAudioStream.clone();
- // Smart Disconnect: Old connection dies immediately upon success
- if (currentUpstreamPC && currentUpstreamPC !== newPC) {
- const oldPC = currentUpstreamPC;
- setTimeout(() => {
- oldPC.close();
- oldUpstreamPCs = oldUpstreamPCs.filter(pc => pc !== oldPC);
- }, 1000);
- }
- currentUpstreamPC = newPC;
- };
+ origAudioTrack = event.track;
+ displayedAudioTrack = origAudioTrack.clone();
+ sentAudioTrack = origAudioTrack.clone();
+ }
- newPC.onicecandidate = (event) => {
- if (event.candidate) socket.emit('signal_msg', { target: senderId, type: 'candidate', candidate: event.candidate });
- };
+ // Rebuild displayedStream
+ const displayedStream = new MediaStream();
+ if (displayedVideoTrack) displayedStream.addTrack(displayedVideoTrack);
+ if (displayedAudioTrack) displayedStream.addTrack(displayedAudioTrack);
- await newPC.setRemoteDescription(new RTCSessionDescription(sdp));
- const answer = await newPC.createAnswer();
- await newPC.setLocalDescription(answer);
- socket.emit('signal_msg', { target: senderId, type: 'answer', sdp: answer });
+ remoteVideo.srcObject = displayedStream;
+ statusDiv.innerText = `Status: Connected | ID: ${socket.id}`;
+
+ if (currentDownstreamPC) {
+ console.log('Relaying new upstream stream to downstream');
+ const videoSender = currentDownstreamPC.getSenders().find((s) => s.track && s.track.kind === 'video');
+ const audioSender = currentDownstreamPC.getSenders().find((s) => s.track && s.track.kind === 'video');
+ if (videoSender) await videoSender.replaceTrack(sentVideoTrack);
+ if (audioSender) await audioSender.replaceTrack(sentAudioTrack);
+ }
+
+ // Smart Disconnect: Old connection dies immediately upon success
+ if (currentUpstreamPC && currentUpstreamPC !== newPC) {
+ const oldPC = currentUpstreamPC;
+ setTimeout(() => {
+ oldPC.close();
+ oldUpstreamPCs = oldUpstreamPCs.filter((pc) => pc !== oldPC);
+ }, 1000);
+ }
+ currentUpstreamPC = newPC;
+ };
+
+ newPC.onicecandidate = (event) => {
+ if (event.candidate) socket.emit('signal_msg', { target: senderId, type: 'candidate', candidate: event.candidate });
+ };
+
+ await newPC.setRemoteDescription(new RTCSessionDescription(sdp));
+ const answer = await newPC.createAnswer();
+ await newPC.setLocalDescription(answer);
+ socket.emit('signal_msg', { target: senderId, type: 'answer', sdp: answer });
}
async function setupDownstreamConnection(targetId) {
- if (currentDownstreamPC) {
- const oldPC = currentDownstreamPC;
- oldDownstreamPCs.push(oldPC);
- setTimeout(() => {
- oldPC.close();
- oldDownstreamPCs = oldDownstreamPCs.filter(pc => pc !== oldPC);
- }, 5000);
- }
+ if (currentDownstreamPC) {
+ const oldPC = currentDownstreamPC;
+ oldDownstreamPCs.push(oldPC);
+ setTimeout(() => {
+ oldPC.close();
+ oldDownstreamPCs = oldDownstreamPCs.filter((pc) => pc !== oldPC);
+ }, 5000);
+ }
- currentDownstreamPC = new RTCPeerConnection({ iceServers });
+ currentDownstreamPC = new RTCPeerConnection({ iceServers });
- if (localStream) {
- localStream.getTracks().forEach(track => currentDownstreamPC.addTrack(track, localStream));
- } else if (currentUpstreamPC) {
- currentUpstreamPC.getReceivers().forEach(receiver => {
- if (receiver.track) currentDownstreamPC.addTrack(receiver.track, remoteVideo.srcObject);
- });
- }
+ if (localStream) {
+ console.log('Sending local stream tracks to downstream');
+ localStream.getTracks().forEach((track) => currentDownstreamPC.addTrack(track, localStream));
+ } else if (currentUpstreamPC) {
+ console.log('Relaying upstream stream tracks to downstream');
+ // currentDownstreamPC.addTrack(sentVideoTrack, sentVideoStream);
- currentDownstreamPC.onicecandidate = (event) => {
- if (event.candidate) socket.emit('signal_msg', { target: targetId, type: 'candidate', candidate: event.candidate });
- };
+ currentUpstreamPC.getReceivers().map((receiver) => {
+ console.log('Receiver track:', receiver.track);
+ if (!receiver.track) return;
- const offer = await currentDownstreamPC.createOffer();
- offer.sdp = offer.sdp.replace(/b=AS:([0-9]+)/g, 'b=AS:4000');
- if (!offer.sdp.includes('b=AS:')) offer.sdp = offer.sdp.replace(/(m=video.*\r\n)/, '$1b=AS:4000\r\n');
+ const sentTrack = receiver.track.kind === 'video' ? sentVideoTrack : sentAudioTrack;
+ const sentStream = receiver.track.kind === 'video' ? sentVideoStream : sentAudioStream;
+ currentDownstreamPC.addTrack(sentTrack, sentStream);
+ });
+ }
- await currentDownstreamPC.setLocalDescription(offer);
- socket.emit('signal_msg', { target: targetId, type: 'offer', sdp: offer });
-}
\ No newline at end of file
+ currentDownstreamPC.onicecandidate = (event) => {
+ if (event.candidate) socket.emit('signal_msg', { target: targetId, type: 'candidate', candidate: event.candidate });
+ };
+
+ await Promise.all(
+ currentDownstreamPC.getSenders().map(async (sender) => {
+ const params = sender.getParameters();
+ params.encodings = params.encodings.map((enc) => {
+ enc.maxBitrate = 200_000_000;
+ enc.maxFramerate = 60;
+ enc.scaleResolutionDownBy = 1.0;
+ enc.priority = 'high';
+ return enc;
+ });
+ params.degradationPreference = 'maintain-resolution';
+ await sender.setParameters(params);
+ })
+ );
+
+ const offer = await currentDownstreamPC.createOffer();
+ // offer.sdp = offer.sdp.replace(/b=AS:([0-9]+)/g, 'b=AS:4000');
+ // if (!offer.sdp.includes('b=AS:')) offer.sdp = offer.sdp.replace(/(m=video.*\r\n)/, '$1b=AS:4000\r\n');
+
+ await currentDownstreamPC.setLocalDescription(offer);
+ socket.emit('signal_msg', { target: targetId, type: 'offer', sdp: offer });
+}
+
+// --- 6. Debug Metrics (bitrate / loss / fps) ---
+
+async function pollPeerMetrics() {
+ try {
+ const upstreamResult = currentUpstreamPC
+ ? await collectInboundMetrics(currentUpstreamPC, metricsHistory.upstream)
+ : null;
+ const downstreamResult = currentDownstreamPC
+ ? await collectOutboundMetrics(currentDownstreamPC, metricsHistory.downstream)
+ : null;
+
+ metricsHistory.upstream = upstreamResult
+ ? upstreamResult.snapshot
+ : currentUpstreamPC
+ ? metricsHistory.upstream
+ : null;
+ metricsHistory.downstream = downstreamResult
+ ? downstreamResult.snapshot
+ : currentDownstreamPC
+ ? metricsHistory.downstream
+ : null;
+
+ renderMetrics(upstreamResult ? upstreamResult.display : null, downstreamResult ? downstreamResult.display : null);
+
+ // Stream metrics to the monitor dashboard
+ socket.emit('report_metrics', {
+ inbound: upstreamResult ? upstreamResult.display : null,
+ outbound: downstreamResult ? downstreamResult.display : null,
+ });
+ } catch (err) {
+ console.warn('Metrics poll failed', err);
+ }
+}
+
+async function collectInboundMetrics(pc, previous) {
+ const stats = await pc.getStats();
+ let inboundVideo = null;
+ let candidatePair = null;
+
+ const tag = pc.__metricsTag || (pc.__metricsTag = Math.random().toString(36).slice(2));
+ const prev = previous && previous.tag === tag ? previous : null;
+
+ stats.forEach((report) => {
+ if (report.type === 'inbound-rtp' && report.kind === 'video' && !report.isRemote) inboundVideo = report;
+ if (report.type === 'candidate-pair' && report.state === 'succeeded' && report.nominated) candidatePair = report;
+ });
+
+ if (!inboundVideo) return null;
+
+ const deltaMs = prev ? inboundVideo.timestamp - prev.timestamp : null;
+ const bytesDelta = prev ? inboundVideo.bytesReceived - prev.bytesReceived : null;
+ const bitrateKbps = deltaMs && deltaMs > 0 && bytesDelta >= 0 ? (bytesDelta * 8) / deltaMs : null; // timestamp is ms
+ const framesDelta =
+ prev && inboundVideo.framesDecoded !== undefined && prev.framesDecoded !== undefined
+ ? inboundVideo.framesDecoded - prev.framesDecoded
+ : null;
+ const fps = framesDelta !== null && deltaMs && deltaMs > 0 ? (framesDelta * 1000) / deltaMs : null;
+
+ const packetLossPct =
+ inboundVideo.packetsLost !== undefined && inboundVideo.packetsReceived !== undefined
+ ? (inboundVideo.packetsLost / (inboundVideo.packetsReceived + inboundVideo.packetsLost)) * 100
+ : null;
+ const jitterMs = inboundVideo.jitter !== undefined ? inboundVideo.jitter * 1000 : null;
+ const rttMs =
+ candidatePair && candidatePair.currentRoundTripTime !== undefined
+ ? candidatePair.currentRoundTripTime * 1000
+ : null;
+
+ const codecReport = inboundVideo.codecId && stats.get ? stats.get(inboundVideo.codecId) : null;
+ const codecLabel = codecReport ? codecReport.mimeType || codecReport.codecId || codecReport.sdpFmtpLine || '' : null;
+
+ return {
+ display: {
+ bitrateKbps,
+ fps,
+ resolution:
+ inboundVideo.frameWidth && inboundVideo.frameHeight
+ ? `${inboundVideo.frameWidth}x${inboundVideo.frameHeight}`
+ : null,
+ packetLossPct,
+ jitterMs,
+ rttMs,
+ pli: inboundVideo.pliCount,
+ nack: inboundVideo.nackCount,
+ fir: inboundVideo.firCount,
+ framesDropped: inboundVideo.framesDropped,
+ codec: codecLabel,
+ state: pc.iceConnectionState || pc.connectionState,
+ },
+ snapshot: {
+ timestamp: inboundVideo.timestamp,
+ bytesReceived: inboundVideo.bytesReceived || 0,
+ framesDecoded: inboundVideo.framesDecoded || 0,
+ tag,
+ },
+ };
+}
+
+async function collectOutboundMetrics(pc, previous) {
+ const stats = await pc.getStats();
+ let outboundVideo = null;
+ let remoteInbound = null;
+ let candidatePair = null;
+
+ const tag = pc.__metricsTag || (pc.__metricsTag = Math.random().toString(36).slice(2));
+ const prev = previous && previous.tag === tag ? previous : null;
+
+ stats.forEach((report) => {
+ if (report.type === 'outbound-rtp' && report.kind === 'video' && !report.isRemote) outboundVideo = report;
+ if (report.type === 'remote-inbound-rtp' && report.kind === 'video') remoteInbound = report;
+ if (report.type === 'candidate-pair' && report.state === 'succeeded' && report.nominated) candidatePair = report;
+ });
+
+ if (!outboundVideo) return null;
+
+ const deltaMs = prev ? outboundVideo.timestamp - prev.timestamp : null;
+ const bytesDelta = prev ? outboundVideo.bytesSent - prev.bytesSent : null;
+ const bitrateKbps = deltaMs && deltaMs > 0 && bytesDelta >= 0 ? (bytesDelta * 8) / deltaMs : null;
+ const framesDelta =
+ prev && outboundVideo.framesEncoded !== undefined && prev.framesEncoded !== undefined
+ ? outboundVideo.framesEncoded - prev.framesEncoded
+ : null;
+ const fps = framesDelta !== null && deltaMs && deltaMs > 0 ? (framesDelta * 1000) / deltaMs : null;
+
+ let packetLossPct = null;
+ if (remoteInbound && remoteInbound.packetsLost !== undefined && remoteInbound.packetsReceived !== undefined) {
+ packetLossPct = (remoteInbound.packetsLost / (remoteInbound.packetsReceived + remoteInbound.packetsLost)) * 100;
+ }
+
+ const rttMs =
+ candidatePair && candidatePair.currentRoundTripTime !== undefined
+ ? candidatePair.currentRoundTripTime * 1000
+ : null;
+ const codecReport = outboundVideo.codecId && stats.get ? stats.get(outboundVideo.codecId) : null;
+ const codecLabel = codecReport ? codecReport.mimeType || codecReport.codecId || codecReport.sdpFmtpLine || '' : null;
+
+ return {
+ display: {
+ bitrateKbps,
+ fps,
+ resolution:
+ outboundVideo.frameWidth && outboundVideo.frameHeight
+ ? `${outboundVideo.frameWidth}x${outboundVideo.frameHeight}`
+ : null,
+ packetLossPct,
+ rttMs,
+ qualityLimit: outboundVideo.qualityLimitationReason || 'none',
+ nack: outboundVideo.nackCount,
+ pli: outboundVideo.pliCount,
+ fir: outboundVideo.firCount,
+ retransmits: outboundVideo.retransmittedPacketsSent,
+ codec: codecLabel,
+ state: pc.iceConnectionState || pc.connectionState,
+ },
+ snapshot: {
+ timestamp: outboundVideo.timestamp,
+ bytesSent: outboundVideo.bytesSent || 0,
+ framesEncoded: outboundVideo.framesEncoded || 0,
+ tag,
+ },
+ };
+}
+
+function renderMetrics(inboundDisplay, outboundDisplay) {
+ if (!currentUpstreamPC) {
+ upstreamMetricsBox.innerHTML = 'No upstream peer (head broadcaster).';
+ } else if (!inboundDisplay) {
+ upstreamMetricsBox.innerHTML = 'Collecting inbound stats...';
+ } else {
+ upstreamMetricsBox.innerHTML = metricsLines([
+ ['State', inboundDisplay.state || '--'],
+ ['Bitrate', formatBitrate(inboundDisplay.bitrateKbps)],
+ ['FPS', formatNumber(inboundDisplay.fps)],
+ ['Resolution', inboundDisplay.resolution || '--'],
+ ['Loss', formatPercent(inboundDisplay.packetLossPct)],
+ ['Jitter', formatMillis(inboundDisplay.jitterMs)],
+ ['RTT', formatMillis(inboundDisplay.rttMs)],
+ ['PLI/NACK/FIR', formatTriple(inboundDisplay.pli, inboundDisplay.nack, inboundDisplay.fir)],
+ ['Frames Dropped', formatCount(inboundDisplay.framesDropped)],
+ ['Codec', inboundDisplay.codec || '--'],
+ ]);
+ }
+
+ if (!currentDownstreamPC) {
+ downstreamMetricsBox.innerHTML = 'No downstream peer connected.';
+ } else if (!outboundDisplay) {
+ downstreamMetricsBox.innerHTML = 'Collecting outbound stats...';
+ } else {
+ downstreamMetricsBox.innerHTML = metricsLines([
+ ['State', outboundDisplay.state || '--'],
+ ['Bitrate', formatBitrate(outboundDisplay.bitrateKbps)],
+ ['FPS', formatNumber(outboundDisplay.fps)],
+ ['Resolution', outboundDisplay.resolution || '--'],
+ ['Loss (remote)', formatPercent(outboundDisplay.packetLossPct)],
+ ['RTT', formatMillis(outboundDisplay.rttMs)],
+ ['Quality Limit', outboundDisplay.qualityLimit || '--'],
+ ['PLI/NACK/FIR', formatTriple(outboundDisplay.pli, outboundDisplay.nack, outboundDisplay.fir)],
+ ['Retransmits', formatCount(outboundDisplay.retransmits)],
+ ['Codec', outboundDisplay.codec || '--'],
+ ]);
+ }
+}
+
+function metricsLines(rows) {
+ return rows
+ .map(([label, value]) => `${label}${value}
`)
+ .join('');
+}
+
+function formatNumber(value, decimals = 1) {
+ return Number.isFinite(value) ? value.toFixed(decimals) : '--';
+}
+
+function formatBitrate(kbps) {
+ return Number.isFinite(kbps) ? `${kbps.toFixed(0)} kbps` : '--';
+}
+
+function formatPercent(value) {
+ return Number.isFinite(value) ? `${value.toFixed(1)}%` : '--';
+}
+
+function formatMillis(value) {
+ return Number.isFinite(value) ? `${value.toFixed(1)} ms` : '--';
+}
+
+function formatCount(value) {
+ return Number.isFinite(value) ? `${value}` : '--';
+}
+
+function formatTriple(a, b, c) {
+ const pa = Number.isFinite(a) ? a : '-';
+ const pb = Number.isFinite(b) ? b : '-';
+ const pc = Number.isFinite(c) ? c : '-';
+ return `${pa}/${pb}/${pc}`;
+}
diff --git a/public/index.html b/public/index.html
index be90293..1dcbacd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,266 +1,318 @@
+
+
+
+ Strandcast
+
-
+ .navbar .brand {
+ margin-bottom: 10px;
+ }
-
-
-
-
-
Join a Broadcast
-
-
-
Go Live
-
-
-
-
-
-
-
-
-
-
Stream Name
-
-
-
Status: Connecting...
-
-
-
-
-
-
+ .nav-links a {
+ margin: 0 10px;
+ }
-
\ No newline at end of file
+ .container {
+ padding: 10px;
+ }
+
+ .card {
+ padding: 15px;
+ }
+
+ h1 {
+ font-size: 20px;
+ }
+ }
+
+
+
+
+
+
+
+
Join a Broadcast
+
+
+
+ Go Live
+
+
+
+
+
+
+
+
+
+
+
Stream Name
+
+
+
Status: Connecting...
+
+
+
Inbound (from upstream)
+
Waiting for data...
+
+
+
Outbound (to downstream)
+
Waiting for data...
+
+
+
+
+
+
+
+
+