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:
1 parent
c2549bfab0
commit
f5c5c593f9
7 files changed
+82
-4
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<body>
|
||||
<header><h1>JohnFeed</h1><span id="conn" class="pill">connecting…</span></header>
|
||||
<div id="fatal" class="banner hidden"></div>
|
||||
<div id="tally" class="tallybar off" role="status" aria-live="polite">Off air</div>
|
||||
<main class="grid">
|
||||
<section class="card" id="audio">
|
||||
<h2>Audio</h2>
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
</div>
|
||||
<p class="muted small">A new link ends the current session and clears chat. Old links stop working.</p>
|
||||
<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="callinfo" class="muted small"></div>
|
||||
<div class="meter"><span>Guest → show</span><div class="bar"><i id="mIn"></i></div><b id="dIn"></b></div>
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
+32
-2
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user