const socket = io(); const remoteVideo = document.getElementById('remoteVideo'); const overlay = document.getElementById('overlay'); let peerConnection; let broadcasterPeerId = null; // Track who the broadcaster is // Fetch TURN credentials dynamically from the server const turnHost = window.location.hostname; let config = { iceServers: [ { urls: `stun:${turnHost}:3478` } ], iceCandidatePoolSize: 5 }; // Load TURN credentials before any connections fetch('/turn-config') .then(r => r.json()) .then(turn => { config = { iceServers: [ { urls: `stun:${turnHost}:3478` }, { urls: `turn:${turnHost}:3478`, username: turn.username, credential: turn.credential } ], iceCandidatePoolSize: 5 }; }) .catch(e => console.error('Failed to load TURN config:', e)); socket.on('offer', (id, description) => { // Track the broadcaster's socket ID so we only react to THEIR disconnect broadcasterPeerId = id; // Close any existing connection before creating a new one if (peerConnection) { peerConnection.close(); peerConnection = null; } peerConnection = new RTCPeerConnection(config); peerConnection.ontrack = event => { remoteVideo.srcObject = event.streams[0]; remoteVideo.classList.add('active'); overlay.classList.add('hidden'); }; // Auto-unmute when the user interacts with the document to bypass browser autoplay restrictions document.addEventListener('click', () => { remoteVideo.muted = false; remoteVideo.volume = 1.0; }, {once: true}); // Monitor ICE connection state for stability peerConnection.oniceconnectionstatechange = () => { console.log('ICE state:', peerConnection.iceConnectionState); if (peerConnection.iceConnectionState === 'failed') { console.log('ICE failed, attempting restart...'); peerConnection.restartIce(); } else if (peerConnection.iceConnectionState === 'disconnected') { setTimeout(() => { if (peerConnection && peerConnection.iceConnectionState === 'disconnected') { console.log('ICE still disconnected, attempting restart...'); peerConnection.restartIce(); } }, 3000); } }; peerConnection.onicecandidate = event => { if (event.candidate) { socket.emit('candidate', id, event.candidate); } }; peerConnection .setRemoteDescription(description) .then(() => peerConnection.createAnswer()) .then(sdp => { let sdpLines = sdp.sdp.split('\r\n'); let opusPayloadType = null; for (let i = 0; i < sdpLines.length; i++) { if (sdpLines[i].includes('a=rtpmap:') && sdpLines[i].includes('opus/48000/2')) { const match = sdpLines[i].match(/a=rtpmap:(\d+) /); if (match) opusPayloadType = match[1]; } } if (opusPayloadType) { let fmtpFound = false; for (let i = 0; i < sdpLines.length; i++) { if (sdpLines[i].startsWith(`a=fmtp:${opusPayloadType}`)) { sdpLines[i] = `a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`; fmtpFound = true; } } if (!fmtpFound) { sdpLines.push(`a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`); } } sdp.sdp = sdpLines.join('\r\n'); return peerConnection.setLocalDescription(sdp); }) .then(() => { socket.emit('answer', id, peerConnection.localDescription); }); }); socket.on('candidate', (id, candidate) => { if (peerConnection) { peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) .catch(e => console.error(e)); } }); socket.on('broadcaster', () => { socket.emit('viewer'); }); // CRITICAL: Only react to the BROADCASTER's disconnect, not other viewers socket.on('disconnectPeer', (id) => { if (id !== broadcasterPeerId) return; // Ignore other viewers disconnecting if (peerConnection) { peerConnection.close(); peerConnection = null; } broadcasterPeerId = null; remoteVideo.classList.remove('active'); remoteVideo.srcObject = null; overlay.classList.remove('hidden'); overlay.querySelector('h1').innerText = 'Stream Ended'; overlay.querySelector('.status-indicator span:last-child').innerText = 'Waiting for new stream...'; }); socket.on('connect', () => { socket.emit('viewer'); });