document.addEventListener('DOMContentLoaded', function () { const API_URL = './api/'; // --- Main UI Elements --- const selectionContainer = document.getElementById('selection-container'); const hostPanelContainer = document.getElementById('host-panel-container'); const participantPanelContainer = document.getElementById('participant-panel-container'); const toastContainer = document.querySelector('.toast-container'); // --- Room Joining Elements --- const joinCodeInput = document.getElementById('join-code'); const joinRoomBtn = document.getElementById('join-room-btn'); // --- Room Creation Elements --- const createRoomBtn = document.getElementById('create-room-btn'); // --- Host Panel Elements --- const hostRoomCodeDisplay = document.getElementById('host-room-code'); const hostStatusDisplay = document.getElementById('host-status'); const startStreamingBtn = document.getElementById('start-streaming-btn'); const deleteRoomBtn = document.getElementById('delete-room-btn'); // --- Participant Panel Elements --- const participantStatusDisplay = document.getElementById('participant-status'); const participantAudio = document.getElementById('participant-audio'); const leaveRoomBtn = document.getElementById('leave-room-btn'); // --- State Management --- let localAudioStream = null; let peerConnection = null; let roomCode = null; let isHost = false; // =================================================================== // 1. EVENT LISTENERS // =================================================================== if (joinCodeInput) { joinCodeInput.addEventListener('input', () => { joinRoomBtn.disabled = joinCodeInput.value.length < 6; }); } if (joinRoomBtn) { joinRoomBtn.addEventListener('click', () => { const code = joinCodeInput.value.toLowerCase(); joinRoom(code); }); } if (createRoomBtn) { createRoomBtn.addEventListener('click', createRoom); } if (startStreamingBtn) { startStreamingBtn.addEventListener('click', () => { if (localAudioStream) { stopStreaming(); } else { startStreaming(); } }); } if (deleteRoomBtn) { deleteRoomBtn.addEventListener('click', () => { stopStreaming(); // TODO: Add server-side room deletion hostPanelContainer.classList.add('d-none'); selectionContainer.classList.remove('d-none'); showToast('Room has been closed.'); }); } if (leaveRoomBtn) { leaveRoomBtn.addEventListener('click', () => { if (peerConnection) { peerConnection.close(); } participantPanelContainer.classList.add('d-none'); selectionContainer.classList.remove('d-none'); showToast('You have left the room.'); }); } // =================================================================== // 2. API & ROOM MANAGEMENT // =================================================================== async function createRoom() { try { const response = await fetch(`${API_URL}?action=create-room`); const data = await response.json(); if (data.roomCode) { roomCode = data.roomCode; isHost = true; hostRoomCodeDisplay.textContent = roomCode; selectionContainer.classList.add('d-none'); hostPanelContainer.classList.remove('d-none'); showToast('Your room is live!', 'success'); listenForSignals(); } } catch (error) { console.error('Error creating room:', error); showToast('Could not create room. Please try again.', 'danger'); } } async function joinRoom(code) { try { const response = await fetch(`${API_URL}?action=get-room-details&roomCode=${code}`); if (!response.ok) throw new Error('Room not found'); const data = await response.json(); roomCode = code; isHost = false; selectionContainer.classList.add('d-none'); participantPanelContainer.classList.remove('d-none'); updateParticipantStatus('joining'); await setupParticipantConnection(data); listenForSignals(); } catch (error) { console.error('Error joining room:', error); showToast(`Could not join room: ${error.message}`, 'danger'); } } // =================================================================== // 3. CORE STREAMING LOGIC (WebRTC) // =================================================================== async function startStreaming() { updateHostStatus('connecting'); try { const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); if (stream.getAudioTracks().length === 0) { stream.getTracks().forEach(track => track.stop()); showToast('No audio was shared. Please enable audio sharing.', 'danger'); updateHostStatus('error'); return; } localAudioStream = stream; updateHostStatus('streaming'); showToast('Streaming started! Waiting for a participant...', 'success'); stream.getAudioTracks()[0].onended = () => stopStreaming(); // --- WebRTC Host Logic --- peerConnection = createPeerConnection(); stream.getTracks().forEach(track => peerConnection.addTrack(track, stream)); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); await signal({ offer }); } catch (err) { console.error('Streaming Error:', err); updateHostStatus('error'); showToast('Could not start streaming.', 'danger'); } } function stopStreaming() { if (localAudioStream) { localAudioStream.getTracks().forEach(track => track.stop()); localAudioStream = null; } if (peerConnection) { peerConnection.close(); peerConnection = null; } updateHostStatus('idle'); console.log('Streaming stopped.'); } async function setupParticipantConnection(roomData) { peerConnection = createPeerConnection(); peerConnection.ontrack = (event) => { updateParticipantStatus('playing'); participantAudio.srcObject = event.streams[0]; participantAudio.play(); }; await peerConnection.setRemoteDescription(new RTCSessionDescription(roomData.offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); await signal({ answer }); // Add host's ICE candidates if (roomData.host_candidates) { for (const candidate of roomData.host_candidates) { await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } } } function createPeerConnection() { const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); pc.onicecandidate = event => { if (event.candidate) { signal({ candidate: event.candidate }); } }; pc.oniceconnectionstatechange = () => { console.log('ICE Connection State:', pc.iceConnectionState); if (isHost) { if (pc.iceConnectionState === 'connected') { showToast('A participant has connected!', 'success'); } } else { if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { updateParticipantStatus('error'); } } }; return pc; } async function signal(data) { await fetch(`${API_URL}?action=signal&roomCode=${roomCode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, isHost }) }); } // Basic polling to get signals from the other party async function listenForSignals() { const interval = setInterval(async () => { try { const response = await fetch(`${API_URL}?action=get-room-details&roomCode=${roomCode}`); const data = await response.json(); if (peerConnection && peerConnection.signalingState === 'stable') return; if (isHost && data.answer && peerConnection.signalingState !== 'stable') { await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer)); // Add participant's ICE candidates if (data.participant_candidates) { for (const candidate of data.participant_candidates) { await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } } } } catch (error) { // console.error('Signaling listener error:', error); } }, 3000); // Note: In a real app, you'd use WebSockets instead of polling. } // =================================================================== // 4. UI HELPER FUNCTIONS // =================================================================== function updateHostStatus(status) { startStreamingBtn.disabled = false; let content = ''; switch (status) { case 'connecting': startStreamingBtn.disabled = true; content = `
Requesting permission...`; hostStatusDisplay.className = 'alert alert-warning'; break; case 'streaming': startStreamingBtn.innerHTML = ' Stop Streaming'; startStreamingBtn.className = 'btn btn-danger btn-lg'; content = ` Now streaming... Waiting for connections.`; hostStatusDisplay.className = 'alert alert-success'; break; case 'error': content = ' Could not start streaming.'; hostStatusDisplay.className = 'alert alert-danger'; break; case 'idle': default: startStreamingBtn.innerHTML = ' Start Streaming'; startStreamingBtn.className = 'btn btn-success btn-lg'; content = 'Not currently streaming.'; hostStatusDisplay.className = 'alert alert-info'; break; } hostStatusDisplay.innerHTML = content; } function updateParticipantStatus(status) { let content = ''; switch (status) { case 'joining': content = `Connecting to host...
`; participantStatusDisplay.className = 'alert alert-info'; break; case 'playing': content = ` Live audio is playing.`; participantStatusDisplay.className = 'alert alert-success'; break; case 'error': content = ' Connection lost. Please try rejoining.'; participantStatusDisplay.className = 'alert alert-danger'; if (participantAudio.srcObject) { participantAudio.srcObject.getTracks().forEach(track => track.stop()); participantAudio.srcObject = null; } break; } participantStatusDisplay.innerHTML = content; } function showToast(message, type = 'info') { if (!toastContainer) return; const bgClass = { info: 'bg-primary', success: 'bg-success', danger: 'bg-danger' }[type] || 'bg-primary'; const toastEl = document.createElement('div'); toastEl.className = `toast align-items-center text-white ${bgClass} border-0`; toastEl.setAttribute('role', 'alert'); toastEl.innerHTML = `