talkie/public/app.js

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// --- 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();
}