305 lines
8.6 KiB
JavaScript
305 lines
8.6 KiB
JavaScript
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 = `
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
<line x1="12" y1="15" x2="12" y2="17"/>
|
|
`;
|
|
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 = `
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
`;
|
|
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 = `
|
|
<div class="peer-avatar">${initial}</div>
|
|
<span class="peer-name">${escapeHtml(name)}</span>
|
|
<span class="talking-badge">● parlando</span>
|
|
`;
|
|
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,'<').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();
|
|
}
|