const socket = io(); let localStream; let peerConnection; // For Viewers: connection to the streamer let viewerConnections = {}; // For Streamers: map of { socketId: RTCPeerConnection } let cloudflareIceServers = []; const videoDisplay = document.getElementById('videoDisplay'); const placeholder = document.getElementById('videoPlaceholder'); const streamListDiv = document.getElementById('streamList'); const goLiveBtn = document.getElementById('goLiveBtn'); const stopLiveBtn = document.getElementById('stopLiveBtn'); // 1. Initialize on Load (Fetch Keys & Listen for streams) (async function init() { try { const res = await fetch('/api/get-turn-credentials'); const data = await res.json(); cloudflareIceServers = data.iceServers; console.log("ICE Servers loaded."); } catch (e) { console.error("Failed to load ICE servers:", e); } })(); // --- VIEWER LOGIC (Default) --- // Listen for updates to the list of available streams socket.on('streamer_list_update', (streamers) => { streamListDiv.innerHTML = ''; const ids = Object.keys(streamers); if (ids.length === 0) { streamListDiv.innerHTML = '
No active streams.
'; return; } ids.forEach(id => { if (id === socket.id) return; // Don't list myself const div = document.createElement('div'); div.className = 'stream-item'; div.innerHTML = ` ${streamers[id].name} `; streamListDiv.appendChild(div); }); }); // User clicked "Watch" on a stream async function watchStream(streamerId) { if (localStream) { alert("You cannot watch while streaming!"); return; } // UI Updates placeholder.classList.add('hidden'); videoDisplay.classList.remove('hidden'); videoDisplay.muted = false; // Unmute for viewer // Tell server we want to join console.log("Requesting to join stream:", streamerId); socket.emit('join_stream', streamerId); // Prepare to receive the Offer socket.off('webrtc_offer'); // Clean previous listeners socket.on('webrtc_offer', async ({ sdp, sender }) => { if (sender !== streamerId) return; console.log("Received Offer from streamer."); peerConnection = new RTCPeerConnection({ iceServers: cloudflareIceServers }); // When we get tracks (video/audio), show them peerConnection.ontrack = (event) => { console.log("Track received"); videoDisplay.srcObject = event.streams[0]; }; // Handle ICE candidates to send back to streamer peerConnection.onicecandidate = (event) => { if (event.candidate) { socket.emit('ice_candidate', { target: sender, candidate: event.candidate }); } }; await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); socket.emit('webrtc_answer', { target: sender, sdp: answer }); }); // Handle ICE candidates from streamer socket.on('ice_candidate', async ({ candidate, sender }) => { if (peerConnection && sender === streamerId) { await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } }); } // --- STREAMER LOGIC --- async function promptForStream() { const name = prompt("Enter a name for your stream:", "My Cool Stream"); if (!name) return; startStreaming(name); } async function startStreaming(streamName) { try { // 1. Get User Media localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); // 2. Update UI videoDisplay.srcObject = localStream; videoDisplay.classList.remove('hidden'); placeholder.classList.add('hidden'); videoDisplay.muted = true; // Mute self to avoid echo goLiveBtn.classList.add('hidden'); stopLiveBtn.classList.remove('hidden'); // 3. Announce to Server socket.emit('start_stream', streamName); console.log("Stream announced:", streamName); // 4. Handle Incoming Viewers socket.on('viewer_joined', async ({ viewerId }) => { console.log("New viewer joined:", viewerId); createStreamerConnection(viewerId); }); // 5. Handle Answers from Viewers socket.on('webrtc_answer', async ({ sdp, sender }) => { const pc = viewerConnections[sender]; if (pc) { await pc.setRemoteDescription(new RTCSessionDescription(sdp)); } }); // 6. Handle ICE Candidates from Viewers socket.on('ice_candidate', async ({ candidate, sender }) => { const pc = viewerConnections[sender]; if (pc) { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } }); // NEW: Handle instant disconnects socket.on('viewer_left', ({ viewerId }) => { console.log(`Viewer ${viewerId} left via socket disconnect.`); const pc = viewerConnections[viewerId]; if (pc) { pc.close(); // Stop sending data immediately delete viewerConnections[viewerId]; } }); } catch (err) { console.error("Error starting stream:", err); alert("Could not access camera/microphone."); } } function createStreamerConnection(viewerId) { const pc = new RTCPeerConnection({ iceServers: cloudflareIceServers }); viewerConnections[viewerId] = pc; // Add local stream tracks to this connection localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // Send ICE candidates to this specific viewer pc.onicecandidate = (event) => { if (event.candidate) { socket.emit('ice_candidate', { target: viewerId, candidate: event.candidate }); } }; // Create Offer pc.createOffer().then(offer => { pc.setLocalDescription(offer); socket.emit('webrtc_offer', { target: viewerId, sdp: offer }); }); // Cleanup on disconnect pc.onconnectionstatechange = () => { if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { delete viewerConnections[viewerId]; } }; }