'use strict'; const log = $('#log'); let ws = null, lastGuestOnline = null; function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); } function connect(){ ws = new WebSocket(wsURL('/ws')); ws.onopen = () => { $('#conn').textContent = 'server connected'; $('#conn').className = 'pill ok'; }; ws.onclose = () => { $('#conn').textContent = 'server offline - retrying'; $('#conn').className = 'pill bad'; setTimeout(connect, 2000); }; ws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.t === 'history') { log.textContent = ''; m.history.forEach(x => renderMsg(log, x, 'host')); } else if (m.t === 'chat') { renderMsg(log, m.msg, 'host'); if (m.msg.who === 'guest' && document.hidden) flashTitle(); } else if (m.t === 'state') render(m); }; } let flashT = null; function flashTitle(){ // visual only - the host page must never make sound on the rig if (flashT) return; const base = document.title; let on = false; flashT = setInterval(() => { document.title = (on = !on) ? '💬 Guest message' : base; }, 1000); const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); }; document.addEventListener('visibilitychange', stop); } function fmtDur(s){ const m = Math.floor(s/60); return m + ':' + String(s%60).padStart(2,'0'); } function render(s){ $('#link').value = s.link || ''; $('#copy').disabled = $('#revoke').disabled = !s.link; const name = s.guestName || 'Guest (anonymous)'; const c = s.call; let line = s.guestOnline ? name + ' - page open' : 'No guest connected'; if (c && c.pc === 'connected') line = name + ' - LIVE ' + fmtDur(c.secs); else if (c) line = name + ' - audio ' + c.pc; $('#guest').textContent = line; $('#callinfo').textContent = c ? ('route: ' + (c.route || '…') + (c.sending ? '' : ' · send stopped') + (c.playing ? '' : ' · playout stopped')) : ''; if (c) { $('#dIn').textContent = Math.max(-60, setMeter($('#mIn'), c.inPeak)).toFixed(0) + ' dB'; $('#dOut').textContent = Math.max(-60, setMeter($('#mOut'), c.outPeak)).toFixed(0) + ' dB'; const j = c.jb; $('#jb').textContent = j ? `buffer ${j.depthMs}/${j.targetMs} ms · lost ${j.lost} (fec ${j.fec}) · concealed ${j.plc} · late ${j.late} · drift-drops ${j.dropped} · inserted ${j.inserted} · underruns ${j.underruns}` : ''; } else { setMeter($('#mIn'), 0); setMeter($('#mOut'), 0); $('#dIn').textContent = $('#dOut').textContent = ''; $('#jb').textContent = ''; } $('#endcall').disabled = !c; $('#kick').disabled = !s.guestOnline && !c; } // Two-click confirm for destructive buttons (no browser dialogs). function confirmBtn(btn, action){ const label = btn.textContent; let armed = null; btn.onclick = () => { if (armed) { clearTimeout(armed); armed = null; btn.textContent = label; action(); return; } btn.textContent = 'Click again to confirm'; armed = setTimeout(() => { armed = null; btn.textContent = label; }, 3000); }; } confirmBtn($('#newlink'), () => send({t:'newlink'})); confirmBtn($('#revoke'), () => send({t:'revoke'})); confirmBtn($('#kick'), () => send({t:'kick'})); confirmBtn($('#endcall'), () => send({t:'endcall'})); confirmBtn($('#clear'), () => send({t:'clearchat'})); $('#copy').onclick = async () => { const el = $('#link'); el.select(); try { await navigator.clipboard.writeText(el.value); } catch(e) { document.execCommand('copy'); } $('#copy').textContent = 'Copied'; setTimeout(() => $('#copy').textContent = 'Copy', 1500); }; chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t})); connect();