Files
guestlink/web/static/host.js
T

99 lines
5.3 KiB
JavaScript

'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(text){ // 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) ? (text || '💬 Guest message') : base; }, 1000);
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
document.addEventListener('visibilitychange', stop);
}
// ---- tally (sign on the guest's page only)
let onAir = false;
$('#tally').onclick = () => send({t:'onair', on: !onAir});
// ---- connection warning: audible trouble on the guest -> show path over the last 10 s.
// Concealed frames (PLC) and underruns are what listeners hear; FEC recoveries are not.
let hist = [], warnLevel = 0;
function checkTrouble(c){
const box = $('#warn');
if (!c) { hist = []; box.className = 'warnbox hidden'; warnLevel = 0; return; }
const now = Date.now(), j = c.jb;
if (j) hist.push({t: now, plc: j.plc, ur: j.underruns});
while (hist.length && now - hist[0].t > 10000) hist.shift();
let plc = 0, ur = 0;
if (hist.length > 1) { const a = hist[0], b = hist[hist.length-1]; plc = b.plc - a.plc; ur = b.ur - a.ur; }
const n = (k, w) => k + ' ' + w + (k === 1 ? '' : 's');
let level = 0, msg = '';
if (c.pc === 'disconnected' || c.pc === 'failed') { level = 2; msg = 'Guest audio ' + c.pc + ' - their network dropped out.'; }
else if (c.pc === 'connected' && (plc >= 25 || ur >= 3)) { level = 2; msg = 'Guest connection is bad: ' + n(plc, 'patched gap') + ', ' + n(ur, 'dropout') + ' in the last 10 s.'; }
else if (c.pc === 'connected' && (plc >= 3 || ur >= 1)) { level = 1; msg = 'Guest connection wobbling: ' + n(plc, 'patched gap') + ', ' + n(ur, 'dropout') + ' in the last 10 s.'; }
box.textContent = msg; box.className = 'warnbox' + (level === 2 ? ' bad' : '') + (level ? '' : ' hidden');
if (level === 2 && warnLevel < 2 && document.hidden) flashTitle('⚠ Guest connection');
warnLevel = level;
}
function fmtDur(s){ const m = Math.floor(s/60); return m + ':' + String(s%60).padStart(2,'0'); }
function render(s){
$('#link').value = s.link || '';
onAir = !!s.onAir;
$('#tally').textContent = onAir ? '● ON AIR' : 'Off air';
$('#tally').classList.toggle('on', onAir);
$('#tally').disabled = !s.link;
checkTrouble(s.call);
$('#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();