diff --git a/README.md b/README.md index f3ca8a2..1067cfc 100644 --- a/README.md +++ b/README.md @@ -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 (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 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), diff --git a/main.go b/main.go index 82cb247..222e1f6 100644 --- a/main.go +++ b/main.go @@ -89,6 +89,7 @@ type Hub struct { call *Call hosts map[*wsConn]bool chat []chatMsg + onAir bool // tally shown on the guest page; set by the host, never automatic udp *net.UDPConn mux ice.UDPMux } @@ -129,6 +130,7 @@ func (h *Hub) stateLocked() map[string]any { "link": h.link(), "guestOnline": h.guest != nil, "guestName": h.guestName, + "onAir": h.onAir, "call": nil, } if h.call != nil { @@ -322,13 +324,14 @@ func guestWS(w http.ResponseWriter, r *http.Request) { old := hub.guest hub.guest = me hist := append([]chatMsg(nil), hub.chat...) + onAir := hub.onAir hub.mu.Unlock() if old != nil { old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."}) old.c.Close() } 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() chatRL := &rateLimiter{n: 8, per: 10 * time.Second} @@ -447,6 +450,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) { var m struct { T string `json:"t"` Text string `json:"text"` + On bool `json:"on"` } if err := c.ReadJSON(&m); err != nil { return @@ -462,6 +466,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) { hub.token = randToken() hub.chat = nil hub.guestName = "" + hub.onAir = false hub.saveToken() hub.mu.Unlock() 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.mu.Lock() hub.token = "" + hub.onAir = false hub.saveToken() hub.mu.Unlock() log.Printf("invite link revoked") @@ -485,6 +491,16 @@ func hostWS(w http.ResponseWriter, r *http.Request) { if g != nil { 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": hub.mu.Lock() hub.chat = nil diff --git a/web/guest.html b/web/guest.html index 30a6b60..4652ffc 100644 --- a/web/guest.html +++ b/web/guest.html @@ -5,6 +5,7 @@

JohnFeed

connecting…
+
Off air

Audio

diff --git a/web/host.html b/web/host.html index dbd3498..9ebe3cc 100644 --- a/web/host.html +++ b/web/host.html @@ -13,6 +13,9 @@

A new link ends the current session and clears chat. Old links stop working.

Guest

+
+ Guest's tally light - sign only, no audio change
+
No guest connected
Guest → show
diff --git a/web/static/guest.js b/web/static/guest.js index d334fff..80c0ccb 100644 --- a/web/static/guest.js +++ b/web/static/guest.js @@ -53,7 +53,9 @@ function connect(){ const m = JSON.parse(ev.data); switch (m.t) { 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 '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; @@ -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; function flashTitle(){ if (flashT) return; const base = document.title; let on = false; diff --git a/web/static/host.js b/web/static/host.js index d160b68..ee8b7e6 100644 --- a/web/static/host.js +++ b/web/static/host.js @@ -16,17 +16,47 @@ function connect(){ } 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; - 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); }; 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; diff --git a/web/static/style.css b/web/static/style.css index 008c1be..f4f156d 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -38,3 +38,9 @@ button.small{padding:3px 10px;font-size:13px} .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} 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}