const STUN_CONFIG = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; let ws = null; let myId = null; let myName = ''; let localStream = null; const peers = new Map(); // peerId -> RTCPeerConnection // --- DOM --- const joinScreen = document.getElementById('join-screen'); const talkieScreen = document.getElementById('talkie-screen'); const inputName = document.getElementById('input-name'); const inputRoom = document.getElementById('input-room'); const btnJoin = document.getElementById('btn-join'); const btnLeave = document.getElementById('btn-leave'); const roomLabel = document.getElementById('room-label'); const peersList = document.getElementById('peers-list'); const pttBtn = document.getElementById('ptt-btn'); const btnLockMic = document.getElementById('btn-lock-mic'); const statusText = document.getElementById('status-text'); let micLocked = false; // --- JOIN --- btnJoin.addEventListener('click', joinRoom); inputRoom.addEventListener('keydown', e => { if (e.key === 'Enter') joinRoom(); }); async function joinRoom() { const name = inputName.value.trim() || 'Anonimo'; const room = inputRoom.value.trim().toUpperCase(); if (!room) { inputRoom.focus(); return; } myName = name; try { localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); } catch { alert('Impossibile accedere al microfono. Controlla i permessi del browser.'); return; } // Mute microphone until PTT pressed setMic(false); connectWS(room, name); } // --- WEBSOCKET --- function connectWS(room, name) { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}`); ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', room, name })); showTalkieScreen(room); }; ws.onmessage = ({ data }) => { const msg = JSON.parse(data); handleSignal(msg); }; ws.onclose = () => { leaveRoom(); }; } async function handleSignal(msg) { switch (msg.type) { case 'id': myId = msg.id; break; case 'peers': // Existing peers: I (new joiner) initiate offers to each for (const peer of msg.peers) { addPeerToUI(peer.id, peer.name); await createOffer(peer.id); } break; case 'peer-joined': addPeerToUI(msg.id, msg.name); // They will send us an offer; just prep the connection getOrCreatePC(msg.id); break; case 'offer': await handleOffer(msg.from, msg.sdp); break; case 'answer': await peers.get(msg.from)?.setRemoteDescription(new RTCSessionDescription(msg.sdp)); break; case 'ice': try { await peers.get(msg.from)?.addIceCandidate(new RTCIceCandidate(msg.candidate)); } catch {} break; case 'peer-left': peers.get(msg.id)?.close(); peers.delete(msg.id); removePeerFromUI(msg.id); break; case 'talking': setPeerTalking(msg.id, msg.active); break; } } // --- WEBRTC --- function getOrCreatePC(peerId) { if (peers.has(peerId)) return peers.get(peerId); const pc = new RTCPeerConnection(STUN_CONFIG); peers.set(peerId, pc); // Add local tracks localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // Remote audio pc.ontrack = ({ streams }) => { attachAudio(peerId, streams[0]); }; // ICE candidates pc.onicecandidate = ({ candidate }) => { if (candidate) { ws.send(JSON.stringify({ type: 'ice', to: peerId, candidate })); } }; pc.onconnectionstatechange = () => { if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) { peers.delete(peerId); } }; return pc; } async function createOffer(peerId) { const pc = getOrCreatePC(peerId); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({ type: 'offer', to: peerId, sdp: pc.localDescription })); } async function handleOffer(peerId, sdp) { const pc = getOrCreatePC(peerId); await pc.setRemoteDescription(new RTCSessionDescription(sdp)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); ws.send(JSON.stringify({ type: 'answer', to: peerId, sdp: pc.localDescription })); } function attachAudio(peerId, stream) { let audio = document.getElementById(`audio-${peerId}`); if (!audio) { audio = document.createElement('audio'); audio.id = `audio-${peerId}`; audio.autoplay = true; audio.style.display = 'none'; document.body.appendChild(audio); } audio.srcObject = stream; } // --- PTT --- function setMic(enabled) { if (!localStream) return; localStream.getAudioTracks().forEach(t => t.enabled = enabled); } function startTalking() { if (micLocked) return; setMic(true); pttBtn.classList.add('active'); statusText.textContent = 'Stai trasmettendo...'; statusText.style.color = '#22c55e'; ws?.send(JSON.stringify({ type: 'talking', active: true })); } function stopTalking() { if (micLocked) return; setMic(false); pttBtn.classList.remove('active'); statusText.textContent = 'Rilascia per ascoltare'; statusText.style.color = ''; ws?.send(JSON.stringify({ type: 'talking', active: false })); } function toggleMicLock() { micLocked = !micLocked; if (micLocked) { setMic(true); pttBtn.classList.add('active'); btnLockMic.classList.add('locked'); btnLockMic.querySelector('svg').innerHTML = ` `; btnLockMic.childNodes[2].textContent = ' Mic attivo — clicca per disattivare'; statusText.textContent = 'Microfono sempre attivo'; statusText.style.color = '#e05252'; ws?.send(JSON.stringify({ type: 'talking', active: true })); } else { setMic(false); pttBtn.classList.remove('active'); btnLockMic.classList.remove('locked'); btnLockMic.querySelector('svg').innerHTML = ` `; btnLockMic.childNodes[2].textContent = ' Microfono sempre attivo'; statusText.textContent = 'Rilascia per ascoltare'; statusText.style.color = ''; ws?.send(JSON.stringify({ type: 'talking', active: false })); } } btnLockMic.addEventListener('click', toggleMicLock); // Mouse events pttBtn.addEventListener('mousedown', startTalking); pttBtn.addEventListener('mouseup', stopTalking); pttBtn.addEventListener('mouseleave', stopTalking); // Touch events (mobile) pttBtn.addEventListener('touchstart', e => { e.preventDefault(); startTalking(); }, { passive: false }); pttBtn.addEventListener('touchend', e => { e.preventDefault(); stopTalking(); }, { passive: false }); // Space bar shortcut document.addEventListener('keydown', e => { if (e.code === 'Space' && !e.repeat && talkieScreen.classList.contains('active')) { e.preventDefault(); startTalking(); } }); document.addEventListener('keyup', e => { if (e.code === 'Space' && talkieScreen.classList.contains('active')) { stopTalking(); } }); // --- UI --- function showTalkieScreen(room) { joinScreen.classList.remove('active'); talkieScreen.classList.add('active'); roomLabel.textContent = `# ${room}`; peersList.innerHTML = ''; } function addPeerToUI(peerId, name) { if (document.getElementById(`peer-${peerId}`)) return; const initial = (name || '?')[0].toUpperCase(); const el = document.createElement('div'); el.className = 'peer-item'; el.id = `peer-${peerId}`; el.innerHTML = `
${initial}
${escapeHtml(name)} ● parlando `; peersList.appendChild(el); } function removePeerFromUI(peerId) { document.getElementById(`peer-${peerId}`)?.remove(); document.getElementById(`audio-${peerId}`)?.remove(); } function setPeerTalking(peerId, active) { const el = document.getElementById(`peer-${peerId}`); if (!el) return; el.classList.toggle('talking', active); } function escapeHtml(str) { return str.replace(/&/g,'&').replace(//g,'>'); } // --- LEAVE --- btnLeave.addEventListener('click', leaveRoom); function leaveRoom() { ws?.close(); ws = null; peers.forEach(pc => pc.close()); peers.clear(); localStream?.getTracks().forEach(t => t.stop()); localStream = null; document.querySelectorAll('audio[id^="audio-"]').forEach(a => a.remove()); talkieScreen.classList.remove('active'); joinScreen.classList.add('active'); peersList.innerHTML = ''; if (micLocked) toggleMicLock(); stopTalking(); }