const socket = io(); const localVideo = document.getElementById('localVideo'); const remoteVideo = document.getElementById('remoteVideo'); const statusDiv = document.getElementById('status'); // --- Global State --- let localStream = null; let iceServers = []; // ACTIVE connections let currentUpstreamPC = null; let currentDownstreamPC = null; let downstreamId = null; // Socket ID of who we are sending to // FADING connections (Garbage bin for smooth transitions) let oldUpstreamPCs = []; let oldDownstreamPCs = []; // --- 1. Initialization --- async function init() { try { const response = await fetch('/api/get-turn-credentials'); const data = await response.json(); iceServers = data.iceServers; console.log("Loaded ICE Servers"); } catch (e) { console.error("Using public STUN"); iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; } } init(); // --- 2. User Actions --- async function startStream() { try { localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = localStream; remoteVideo.style.display = 'none'; // Head doesn't need remote view socket.emit('start_stream'); statusDiv.innerText = "Status: Broadcasting (Head)"; } catch (err) { console.error("Error accessing media:", err); alert("Could not access camera."); } } function joinStream() { socket.emit('join_stream'); localVideo.style.display = 'none'; // Nodes don't see themselves statusDiv.innerText = "Status: Joining chain..."; } // --- 3. Health Reporting (The "Bubble Sort" Logic) --- setInterval(calculateAndReportHealth, 5000); async function calculateAndReportHealth() { // If I am Head, I am perfect. if (localStream) { socket.emit('update_score', 100); return; } // If I'm not connected, I'm waiting. if (!currentUpstreamPC) return; 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; } }); const totalPackets = packetsReceived + packetsLost; if (totalPackets === 0) return; // Calculate Score (0-100) // Heavy penalty for loss, mild for jitter 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) { // console.error("Stats error", e); } } // --- 4. Socket Events --- // Instruction: Connect to a new node downstream socket.on('connect_to_downstream', async ({ downstreamId: targetId }) => { console.log(`Instruction: Connect downstream to ${targetId}`); downstreamId = targetId; await setupDownstreamConnection(targetId); }); // Instruction: Stop sending downstream (I am now the tail) socket.on('disconnect_downstream', () => { console.log("Instruction: Disconnect downstream (Became Tail)"); if (currentDownstreamPC) { currentDownstreamPC.close(); currentDownstreamPC = null; downstreamId = null; } // Also clean up garbage bin oldDownstreamPCs.forEach(pc => pc.close()); oldDownstreamPCs = []; }); // Signaling Router socket.on('signal_msg', async ({ sender, type, sdp, candidate }) => { // A. OFFERS (Always come from Upstream) if (type === 'offer') { await handleUpstreamOffer(sender, sdp); } // B. ANSWERS (Always come from Downstream) else if (type === 'answer') { if (currentDownstreamPC && sender === downstreamId) { await currentDownstreamPC.setRemoteDescription(new RTCSessionDescription(sdp)); } } // C. CANDIDATES (Could be for anyone) else if (type === 'candidate') { const ice = new RTCIceCandidate(candidate); // Try Active Downstream if (currentDownstreamPC && sender === downstreamId) { currentDownstreamPC.addIceCandidate(ice).catch(e => { }); } // Try Active Upstream if (currentUpstreamPC) { currentUpstreamPC.addIceCandidate(ice).catch(e => { }); } // Try Fading Connections (Crucial for smooth swap!) oldUpstreamPCs.forEach(pc => pc.addIceCandidate(ice).catch(e => { })); oldDownstreamPCs.forEach(pc => pc.addIceCandidate(ice).catch(e => { })); } }); socket.on('error_msg', (msg) => alert(msg)); socket.on('request_keyframe', () => console.log("Keyframe requested by network")); socket.on('stream_ended', () => { alert("Stream ended"); location.reload(); }); // --- 5. WebRTC Logic (Make-Before-Break) --- // --- UPSTREAM Handling (Receiving) --- async function handleUpstreamOffer(senderId, sdp) { console.log(`Negotiating new Upstream connection from ${senderId}`); const newPC = new RTCPeerConnection({ iceServers }); newPC.ontrack = (event) => { console.log(`New Upstream Track (${event.track.kind}) Active.`); // A. Update Screen (Idempotent: doing this twice is fine) remoteVideo.srcObject = event.streams[0]; statusDiv.innerText = "Status: Connected (Stable)"; // B. Hot-Swap the Relay if (currentDownstreamPC) { const sender = currentDownstreamPC.getSenders().find(s => s.track && s.track.kind === event.track.kind); if (sender) { sender.replaceTrack(event.track); } } // C. Retire the OLD connection (THE FIX IS HERE) // We only queue the old PC if it exists AND it isn't the one we just created. if (currentUpstreamPC && currentUpstreamPC !== newPC) { const oldPC = currentUpstreamPC; oldUpstreamPCs.push(oldPC); console.log("Queueing old upstream connection for death in 4s..."); setTimeout(() => { oldPC.close(); // Remove from garbage bin oldUpstreamPCs = oldUpstreamPCs.filter(pc => pc !== oldPC); console.log("Closed old upstream connection."); }, 4000); } // D. Set New as Current 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 }); } // --- DOWNSTREAM Handling (Sending) --- async function setupDownstreamConnection(targetId) { console.log(`Setting up downstream to ${targetId}`); // 1. Retire existing downstream (Fade Out) if (currentDownstreamPC) { console.log("Moving current downstream to background (Overlap Mode)"); const oldPC = currentDownstreamPC; oldDownstreamPCs.push(oldPC); // Keep sending for 5 seconds so they have time to connect to their NEW source setTimeout(() => { oldPC.close(); oldDownstreamPCs = oldDownstreamPCs.filter(pc => pc !== oldPC); console.log("Closed old downstream connection"); }, 5000); } // 2. Create NEW downstream currentDownstreamPC = new RTCPeerConnection({ iceServers }); // 3. Add Tracks if (localStream) { // Head: Send Camera localStream.getTracks().forEach(track => currentDownstreamPC.addTrack(track, localStream)); } else if (currentUpstreamPC) { // Relay: Send what we are receiving currentUpstreamPC.getReceivers().forEach(receiver => { if (receiver.track) { // "remoteVideo.srcObject" ensures stream ID consistency currentDownstreamPC.addTrack(receiver.track, remoteVideo.srcObject); } }); } currentDownstreamPC.onicecandidate = (event) => { if (event.candidate) { socket.emit('signal_msg', { target: targetId, type: 'candidate', candidate: event.candidate }); } }; const offer = await currentDownstreamPC.createOffer(); // 4. BITRATE HACK: Force 4Mbps limit (Standard WebRTC defaults low) offer.sdp = offer.sdp.replace(/b=AS:([0-9]+)/g, 'b=AS:4000'); if (!offer.sdp.includes('b=AS:')) { // If no bandwidth line exists, add it to the video section 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 }); }