const socket = io(); const localVideo = document.getElementById('localVideo'); const remoteVideo = document.getElementById('remoteVideo'); const statusDiv = document.getElementById('status'); const streamListUl = document.getElementById('streamList'); const lobbyView = document.getElementById('lobby-view'); const streamView = document.getElementById('stream-view'); const streamTitle = document.getElementById('currentStreamTitle'); // --- Global State --- let localStream = null; let iceServers = []; // ACTIVE connections let currentUpstreamPC = null; let currentDownstreamPC = null; let downstreamId = null; // FADING connections 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; } catch (e) { iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; } } init(); // --- 2. Lobby Logic --- // 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; } 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]; if (!name) return alert("Please enter a name"); 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); // 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(); // 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."); } // 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: true, audio: true }); localVideo.srcObject = localStream; localVideo.muted = true; // Mute local webcam to avoid feedback loop } // --- 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}`; 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}`; 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 } // --- 3. Health Reporting (Unchanged) --- setInterval(calculateAndReportHealth, 5000); async function calculateAndReportHealth() { 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; 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 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) { } } // --- 4. Socket Events (Logic Updated for Smart Disconnect) --- socket.on('connect_to_downstream', async ({ downstreamId: targetId }) => { downstreamId = targetId; await setupDownstreamConnection(targetId); }); socket.on('disconnect_downstream', () => { 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 => { })); } }); socket.on('error_msg', (msg) => { alert(msg); location.reload(); }); socket.on('stream_ended', () => { 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. }); // --- 5. WebRTC Logic (Merged Smart Disconnect) --- async function handleUpstreamOffer(senderId, sdp) { const newPC = new RTCPeerConnection({ iceServers }); // Safety: If connection hangs, kill old one eventually let safetyTimer = setTimeout(() => { if (currentUpstreamPC && currentUpstreamPC !== newPC) { currentUpstreamPC.close(); } }, 15000); newPC.ontrack = (event) => { clearTimeout(safetyTimer); // Success! remoteVideo.srcObject = event.streams[0]; statusDiv.innerText = `Status: Connected | ID: ${socket.id}`; if (currentDownstreamPC) { const sender = currentDownstreamPC.getSenders().find(s => s.track && s.track.kind === event.track.kind); if (sender) sender.replaceTrack(event.track); } // 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); } 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); }); } currentDownstreamPC.onicecandidate = (event) => { if (event.candidate) socket.emit('signal_msg', { target: targetId, type: 'candidate', candidate: event.candidate }); }; 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 }); }