Add guest tally (on air / off air) and host connection warning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
namandClaude Opus 5.5 committed 2026-11-06 11:35:10 -06:00
1 parent c2549bfab0
commit f5c5c593f9
7 files changed
+82 -4

No files matched your search

+10
View File
@@ -61,6 +61,16 @@ New link (ends the session, clears chat), Revoke, Copy, End audio, Kick, Clear c
live meters for both directions and the selected network route. The page never plays sound live meters for both directions and the selected network route. The page never plays sound
(it is on the rig): a new guest message flashes the tab title. (it is on the rig): a new guest message flashes the tab title.
**Tally (On air / Off air)**, added 2026-11-06: a button on the console sets a big ON AIR / Off air
bar on the guest's page. It is **only a sign**: it changes no audio, and the guest is heard
whenever their fader is up. It is set by hand and reset to Off air by New link / Revoke.
**Connection warning**, added 2026-11-06: a banner on the console when the guest -> show audio
is audibly struggling over the last 10 s. "Wobbling" means 3 or more patched gaps (concealed
frames) or one buffer dropout. "Bad" (red) means 25 or more gaps, 3 or more dropouts, or the
connection dropping. FEC recoveries are inaudible and do not count. When it turns red and the
tab is in the background, the tab title flashes (no sound).
## Chat ## Chat
Anonymous: no accounts, and the guest's name is optional (default "Guest"). The guest sees Anonymous: no accounts, and the guest's name is optional (default "Guest"). The guest sees
the host as `-host-label` (default "Host"). Messages are kept **in memory only** (last 300), the host as `-host-label` (default "Host"). Messages are kept **in memory only** (last 300),
+17 -1
View File
@@ -89,6 +89,7 @@ type Hub struct {
call *Call call *Call
hosts map[*wsConn]bool hosts map[*wsConn]bool
chat []chatMsg chat []chatMsg
onAir bool // tally shown on the guest page; set by the host, never automatic
udp *net.UDPConn udp *net.UDPConn
mux ice.UDPMux mux ice.UDPMux
} }
@@ -129,6 +130,7 @@ func (h *Hub) stateLocked() map[string]any {
"link": h.link(), "link": h.link(),
"guestOnline": h.guest != nil, "guestOnline": h.guest != nil,
"guestName": h.guestName, "guestName": h.guestName,
"onAir": h.onAir,
"call": nil, "call": nil,
} }
if h.call != nil { if h.call != nil {
@@ -322,13 +324,14 @@ func guestWS(w http.ResponseWriter, r *http.Request) {
old := hub.guest old := hub.guest
hub.guest = me hub.guest = me
hist := append([]chatMsg(nil), hub.chat...) hist := append([]chatMsg(nil), hub.chat...)
onAir := hub.onAir
hub.mu.Unlock() hub.mu.Unlock()
if old != nil { if old != nil {
old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."}) old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."})
old.c.Close() old.c.Close()
} }
log.Printf("guest connected from %s", r.Header.Get("X-Real-IP")) log.Printf("guest connected from %s", r.Header.Get("X-Real-IP"))
me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist}) me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist, "onAir": onAir})
hub.pushState() hub.pushState()
chatRL := &rateLimiter{n: 8, per: 10 * time.Second} chatRL := &rateLimiter{n: 8, per: 10 * time.Second}
@@ -447,6 +450,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
var m struct { var m struct {
T string `json:"t"` T string `json:"t"`
Text string `json:"text"` Text string `json:"text"`
On bool `json:"on"`
} }
if err := c.ReadJSON(&m); err != nil { if err := c.ReadJSON(&m); err != nil {
return return
@@ -462,6 +466,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
hub.token = randToken() hub.token = randToken()
hub.chat = nil hub.chat = nil
hub.guestName = "" hub.guestName = ""
hub.onAir = false
hub.saveToken() hub.saveToken()
hub.mu.Unlock() hub.mu.Unlock()
log.Printf("new invite link created") log.Printf("new invite link created")
@@ -471,6 +476,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
hub.kickGuest("The host closed this session.") hub.kickGuest("The host closed this session.")
hub.mu.Lock() hub.mu.Lock()
hub.token = "" hub.token = ""
hub.onAir = false
hub.saveToken() hub.saveToken()
hub.mu.Unlock() hub.mu.Unlock()
log.Printf("invite link revoked") log.Printf("invite link revoked")
@@ -485,6 +491,16 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
if g != nil { if g != nil {
g.send(map[string]any{"t": "callended"}) g.send(map[string]any{"t": "callended"})
} }
case "onair":
hub.mu.Lock()
hub.onAir = m.On
g := hub.guest
hub.mu.Unlock()
log.Printf("tally: on air %v", m.On)
if g != nil {
g.send(map[string]any{"t": "onair", "on": m.On})
}
hub.pushState()
case "clearchat": case "clearchat":
hub.mu.Lock() hub.mu.Lock()
hub.chat = nil hub.chat = nil
+1
View File
@@ -5,6 +5,7 @@
<body> <body>
<header><h1>JohnFeed</h1><span id="conn" class="pill">connecting…</span></header> <header><h1>JohnFeed</h1><span id="conn" class="pill">connecting…</span></header>
<div id="fatal" class="banner hidden"></div> <div id="fatal" class="banner hidden"></div>
<div id="tally" class="tallybar off" role="status" aria-live="polite">Off air</div>
<main class="grid"> <main class="grid">
<section class="card" id="audio"> <section class="card" id="audio">
<h2>Audio</h2> <h2>Audio</h2>
+3
View File
@@ -13,6 +13,9 @@
</div> </div>
<p class="muted small">A new link ends the current session and clears chat. Old links stop working.</p> <p class="muted small">A new link ends the current session and clears chat. Old links stop working.</p>
<h2>Guest</h2> <h2>Guest</h2>
<div class="row"><button id="tally" class="tally" title="Shows ON AIR / Off air on the guest's page. Does not change any audio.">Off air</button>
<span class="muted small">Guest's tally light - sign only, no audio change</span></div>
<div id="warn" class="warnbox hidden" role="status"></div>
<div id="guest" class="big">No guest connected</div> <div id="guest" class="big">No guest connected</div>
<div id="callinfo" class="muted small"></div> <div id="callinfo" class="muted small"></div>
<div class="meter"><span>Guest &rarr; show</span><div class="bar"><i id="mIn"></i></div><b id="dIn"></b></div> <div class="meter"><span>Guest &rarr; show</span><div class="bar"><i id="mIn"></i></div><b id="dIn"></b></div>
+13 -1
View File
@@ -53,7 +53,9 @@ function connect(){
const m = JSON.parse(ev.data); const m = JSON.parse(ev.data);
switch (m.t) { switch (m.t) {
case 'welcome': case 'history': case 'welcome': case 'history':
log.textContent = ''; (m.history || []).forEach(x => renderMsg(log, x, 'guest')); break; log.textContent = ''; (m.history || []).forEach(x => renderMsg(log, x, 'guest'));
if (m.t === 'welcome') setTally(!!m.onAir, false); break;
case 'onair': setTally(!!m.on, true); break;
case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break; case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break;
case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break; case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break;
case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break; case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break;
@@ -64,6 +66,16 @@ function connect(){
}; };
} }
// ---- tally: a sign set by the host. It does not change any audio.
let tallyOn = null;
function setTally(on, announce){
if (on === tallyOn) return; tallyOn = on;
const el = $('#tally');
el.textContent = on ? '● ON AIR' : 'Off air';
el.className = 'tallybar ' + (on ? 'on' : 'off');
if (announce) note('host set the tally: ' + (on ? 'ON AIR' : 'off air'));
}
let flashT = null; let flashT = null;
function flashTitle(){ function flashTitle(){
if (flashT) return; const base = document.title; let on = false; if (flashT) return; const base = document.title; let on = false;
+32 -2
View File
@@ -16,17 +16,47 @@ function connect(){
} }
let flashT = null; let flashT = null;
function flashTitle(){ // visual only - the host page must never make sound on the rig 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; if (flashT) return; const base = document.title; let on = false;
flashT = setInterval(() => { document.title = (on = !on) ? '💬 Guest message' : base; }, 1000); flashT = setInterval(() => { document.title = (on = !on) ? (text || '💬 Guest message') : base; }, 1000);
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); }; const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
document.addEventListener('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 fmtDur(s){ const m = Math.floor(s/60); return m + ':' + String(s%60).padStart(2,'0'); }
function render(s){ function render(s){
$('#link').value = s.link || ''; $('#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; $('#copy').disabled = $('#revoke').disabled = !s.link;
const name = s.guestName || 'Guest (anonymous)'; const name = s.guestName || 'Guest (anonymous)';
const c = s.call; const c = s.call;
+6
View File
@@ -38,3 +38,9 @@ button.small{padding:3px 10px;font-size:13px}
.diag{margin-top:14px;font-size:14px}.diag summary{cursor:pointer} .diag{margin-top:14px;font-size:14px}.diag summary{cursor:pointer}
.diaglog{max-height:240px;overflow:auto;background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:8px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;margin:0} .diaglog{max-height:240px;overflow:auto;background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:8px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;margin:0}
button.on{border-color:var(--ok);color:var(--ok)} button.on{border-color:var(--ok);color:var(--ok)}
.tallybar{margin:16px 16px 0;padding:10px 16px;border-radius:8px;text-align:center;font-weight:700;letter-spacing:.06em;border:1px solid var(--line);color:var(--muted);background:var(--card)}
.tallybar.on,button.tally.on{background:#d11a1a;border-color:#d11a1a;color:#fff}
.tallybar.on{font-size:20px;letter-spacing:.12em}
button.tally{font-weight:700;min-width:120px}
.warnbox{margin:8px 0;padding:8px 12px;border-radius:7px;border:1px solid var(--warn);background:var(--sys);font-size:14px}
.warnbox.bad{border-color:var(--danger);color:var(--danger);font-weight:600}