openvibers/openvibe.live Commits
Opt-in low-bitrate alert, and the disconnect alert for encoder streams (roadmap D20 parity: audible disconnect and low-bitrate alerts). The broadcaster's opt-in disconnect beep existed for browser broadcasts only: the encoder settings' 'Disconnect Audio' toggle was never consulted because only WebRTC connection states raised the alert, and the banner went inside the hidden browser section. The RTMP feed stopping after it was received now raises it (above the feed status; cleared when the feed returns or the stream ends). The low-bitrate alert was missing: with 'Low Bitrate Alert' on (Audio settings, off by default), a connected browser broadcast whose upload stays under 30% of its target for three stats samples (~18 s) gets one warning and a lower beep per episode. Both opt-ins are read from this browser's storage when the page loads and restored into every toggle (the encoder toggle was never restored). test/broadcast-alerts.test.js runs the page's own alert functions on a fake DOM: browser drops, RTMP feed loss/return/end, the low-bitrate threshold, episode and opt-in rules, and the toggles' wiring.
Changes
@@@ -51,8 +51,12 @@ The broadcast page includes an embedded chat sidebar with:- Mobile: floating action button to toggle chat overlay### Disconnect Handling--- Visual disconnect alert banner when connection drops--- **Audible alert** — opt-in audio beep on disconnect (enable in Audio settings → "Disconnect Sound Alert")++- Visual disconnect alert banner when the connection drops (a browser broadcast's connection, or the++ encoder's RTMP feed stopping after it was received)++- **Audible alert** — opt-in audio beep on disconnect (enable in Audio settings → "Disconnect Sound Alert",++ or Alerts → "Disconnect Audio" in the encoder settings; one preference, kept in this browser)++- **Low-bitrate alert** — opt-in (Audio settings → "Low Bitrate Alert"): when a browser broadcast's upload++ stays under 30% of its target bitrate for about 18 seconds, a warning and a lower beep, once until it recovers- Automatic reconnection with exponential backoff (3s → 30s max)- Connection status indicator (protocol name when connected, status when not)@@@ -906,6 +906,10 @@ WantedBy=multi-user.target</code><span class="bc-setting-label">Disconnect Sound Alert</span><div class="bc-setting-control"><label class="bc-toggle"><input type="checkbox" id="bc-disconnectAudio" onchange="_disconnectAudioEnabled = this.checked; try { localStorage.setItem('bc-disconnect-audio', this.checked ? '1' : '0'); } catch{}"><span class="bc-toggle-slider"></span></label></div></div>++ <div class="bc-setting-row" title="Warn (with a lower beep) when your upload stays under 30% of the target bitrate for about 18 seconds">++ <span class="bc-setting-label">Low Bitrate Alert</span>++ <div class="bc-setting-control"><label class="bc-toggle"><input type="checkbox" id="bc-lowBitrateAudio" onchange="_lowBitrateAlertEnabled = this.checked; try { localStorage.setItem('bc-lowbitrate-audio', this.checked ? '1' : '0'); } catch{}"><span class="bc-toggle-slider"></span></label></div>++ </div></div><!-- Interactive Controls Profile (live) -->@@@ -1850,6 +1850,10 @@ async function showRTMPInstructions(stream) {startRtmpStatusPoll(stream.id);}++// Was the encoder's feed received at the last poll, and has it been lost since? Losing it is a disconnect.++let _rtmpWasReceiving = false;++let _rtmpFeedLost = false;++function startRtmpStatusPoll(streamId) {stopRtmpStatusPoll();setRtmpStatusUI(false);@@@ -1865,9 +1869,17 @@ function startRtmpStatusPoll(streamId) {function stopRtmpStatusPoll() {if (_rtmpStatusPollTimer) { clearInterval(_rtmpStatusPollTimer); _rtmpStatusPollTimer = null; }++ _rtmpWasReceiving = false;++ if (_rtmpFeedLost) { _rtmpFeedLost = false; dismissDisconnectAlert(); }}function setRtmpStatusUI(receiving, connectedAt) {++ // The encoder's feed stopping after it was received is a disconnect: the same banner and the same++ // opt-in sound as a browser broadcast. Only browser connection states raised it before, so the++ // encoder settings' "Disconnect Audio" toggle never sounded.++ if (!receiving && _rtmpWasReceiving) { _rtmpFeedLost = true; showDisconnectAlert('the encoder\'s RTMP feed stopped'); }++ else if (receiving && _rtmpFeedLost) { _rtmpFeedLost = false; dismissDisconnectAlert(); }++ _rtmpWasReceiving = !!receiving;const wrap = document.getElementById('bc-rtmp-status');if (!wrap) return;const spinner = document.getElementById('bc-rtmp-status-spinner');@@@ -2120,11 +2132,16 @@ function syncSettingsUI() {setCheck('bc-autoGain', s.autoGain); setCheck('bc-echoCancellation', s.echoCancellation);setCheck('bc-noiseSuppression', s.noiseSuppression); setCheck('bc-manualGainEnabled', s.manualGainEnabled);setCheck('bc-force48kSampleRate', s.force48kSampleRate);-- // Restore disconnect audio alert from localStorage (not synced to server — local preference)++ // Restore the opt-in alerts from localStorage (not synced to server — local preferences). Both++ // disconnect toggles (browser and encoder settings) show the one preference.try {const discAudio = localStorage.getItem('bc-disconnect-audio') === '1';_disconnectAudioEnabled = discAudio;setCheck('bc-disconnectAudio', discAudio);++ setCheck('bc-ext-disconnectAudio', discAudio);++ const lowBitrate = localStorage.getItem('bc-lowbitrate-audio') === '1';++ _lowBitrateAlertEnabled = lowBitrate;++ setCheck('bc-lowBitrateAudio', lowBitrate);} catch {}setVal('bc-broadcastRes', s.broadcastRes); setVal('bc-broadcastFps', s.broadcastFps);setVal('bc-broadcastCodec', s.broadcastCodec); setVal('bc-broadcastBps', s.broadcastBps);@@@ -2487,6 +2504,7 @@ function startGlobalDisplayTimers() {healthEl.textContent = health;healthEl.style.color = color;}++ _checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);} finally {if (ss) ss.statsPollPending = false;}@@@ -6589,18 +6607,19 @@ function stopRtmpPreview() {/* ── Disconnect Alert Banner + Audible Alert ─────────────────── */let _disconnectAlertShown = false;--let _disconnectAudioEnabled = false; // Default off — user must opt in++/** A stored opt-in (this browser only); off unless the broadcaster turned it on. */++function _alertOptIn(key) { try { return localStorage.getItem(key) === '1'; } catch { return false; } }++let _disconnectAudioEnabled = _alertOptIn('bc-disconnect-audio'); // Default off — user must opt in--/** Play an audible alert beep using Web Audio API */--function _playDisconnectBeep() {-- if (!_disconnectAudioEnabled) return;++/** Two short beeps through Web Audio (the pitch tells the alerts apart). */++function _playAlertBeep(frequency) {try {const ctx = new (window.AudioContext || window.webkitAudioContext)();const osc = ctx.createOscillator();const gain = ctx.createGain();osc.connect(gain);gain.connect(ctx.destination);-- osc.frequency.value = 800;++ osc.frequency.value = frequency;osc.type = 'square';gain.gain.value = 0.3;osc.start();@@@ -6614,6 +6633,12 @@ function _playDisconnectBeep() {} catch { /* Web Audio not available */ }}++/** Play the disconnect beep, if the broadcaster opted in */++function _playDisconnectBeep() {++ if (!_disconnectAudioEnabled) return;++ _playAlertBeep(800);++}++function showDisconnectAlert(reason) {if (_disconnectAlertShown) return;_disconnectAlertShown = true;@@@ -6622,14 +6647,41 @@ function showDisconnectAlert(reason) {banner = document.createElement('div');banner.id = 'bc-disconnect-alert';banner.className = 'bc-disconnect-alert';-- const container = document.querySelector('.bc-video-container') || document.getElementById('bc-live-section');-- if (container) container.parentElement.insertBefore(banner, container);}++ // Above the browser preview, or above the RTMP feed status when the stream comes from an encoder++ // (the browser section is hidden then, and a banner inside it was never seen).++ const browserBox = document.getElementById('bc-browser-broadcast');++ const anchor = (browserBox && browserBox.style.display !== 'none' && document.querySelector('.bc-video-container'))++ || document.getElementById('bc-rtmp-status') || document.getElementById('bc-live-section');++ if (anchor && anchor.previousElementSibling !== banner) anchor.parentElement.insertBefore(banner, anchor);banner.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> <span>Connection lost — ${reason || 'reconnecting...'}</span><button class="btn btn-small btn-outline" onclick="dismissDisconnectAlert()"><i class="fa-solid fa-xmark"></i></button>`;banner.style.display = '';_playDisconnectBeep();}++/* ── Low-Bitrate Alert (opt-in) ──────────────────────────────── */++let _lowBitrateAlertEnabled = _alertOptIn('bc-lowbitrate-audio'); // Default off — user must opt in++const LOW_BITRATE_RATIO = 0.3; // measured upload under 30% of the target bitrate…++const LOW_BITRATE_SAMPLES = 3; // …in three stats samples in a row (6 s apart, so ≈18 s)++++/**++ * Called with every stats sample of the active browser stream. A connected stream whose upload stays++ * well under its target warns once per episode (toast + a lower beep), and only when the broadcaster++ * opted in: a still scene can legitimately send little, so this is never on by default.++ */++function _checkLowBitrate(ss, bitrateKbps, connState) {++ if (!ss) return;++ const targetKbps = getTargetVideoBitrate() / 1000;++ const connected = connState === 'connected' || connState === 'completed';++ const low = connected && bitrateKbps > 0 && bitrateKbps < targetKbps * LOW_BITRATE_RATIO;++ if (!low) { ss._lowBitrateSamples = 0; ss._lowBitrateAlerted = false; return; }++ ss._lowBitrateSamples = (ss._lowBitrateSamples || 0) + 1;++ if (ss._lowBitrateSamples < LOW_BITRATE_SAMPLES || ss._lowBitrateAlerted || !_lowBitrateAlertEnabled) return;++ ss._lowBitrateAlerted = true;++ toast(`Low bitrate: ${bitrateKbps} kbps of ${Math.round(targetKbps)} kbps — viewers may see stutter or blur`, 'warning');++ _playAlertBeep(440);++}++function dismissDisconnectAlert() {_disconnectAlertShown = false;const banner = document.getElementById('bc-disconnect-alert');nnew file mode 100644@@@ -0,0 +1,182 @@++/**++ * Legacy parity (roadmap D20): opt-in audible disconnect and low-bitrate alerts for the broadcaster.++ *++ * Runs the broadcast page's own alert functions (public/js/broadcast.js) against a small fake DOM:++ * - a browser broadcast's connection dropping shows the banner; the beep sounds only when opted in;++ * - an encoder's RTMP feed stopping after it was received does the same (before, only browser++ * connection states raised it, so the encoder settings' "Disconnect Audio" toggle never sounded,++ * and the banner was put inside the hidden browser section);++ * - a connected browser broadcast whose upload stays under 30% of its target for three stats samples++ * warns once per episode, with a lower beep, only when opted in (this alert was missing);++ * - both preferences are off by default, kept in this browser, and restored into every toggle.++ *++ * node test/broadcast-alerts.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++const bc = read('public/js/broadcast.js');++const fragment = read('public/fragments/broadcast.html');++++/** The source of a top-level function declaration, braces matched. */++function extract(src, name) {++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);++ assert.ok(m, `${name}() must exist`);++ let i = src.indexOf('{', m.index);++ let depth = 0;++ for (; i < src.length; i++) {++ if (src[i] === '{') depth++;++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);++ }++ throw new Error(`unbalanced ${name}`);++}++const constant = (name) => Number(new RegExp(`const ${name} = ([\\d.]+);`).exec(bc)[1]);++++/** A tiny DOM: elements by id, each with style/className/textContent and an insertBefore log. */++function fakeDom(mode) {++ const els = {};++ const inserted = [];++ const parent = { insertBefore: (node, before) => inserted.push([node.id, before.id]) };++ const el = (id, extra = {}) => (els[id] = { id, style: {}, className: '', textContent: '', innerHTML: '', parentElement: parent, previousElementSibling: null, ...extra });++ for (const id of ['bc-rtmp-status', 'bc-rtmp-status-spinner', 'bc-rtmp-status-ok', 'bc-rtmp-status-label', 'bc-rtmp-status-detail', 'bc-live-section', 'bc-connection-status']) el(id);++ el('bc-browser-broadcast', { style: { display: mode === 'browser' ? '' : 'none' } });++ const videoBox = { id: 'bc-video-container', parentElement: parent, previousElementSibling: null };++ const document = {++ getElementById: (id) => els[id] || null,++ createElement: () => ({ id: '', className: '', style: {}, innerHTML: '' }),++ querySelector: (sel) => (sel === '.bc-video-container' ? videoBox : null),++ };++ return { els, inserted, document };++}++++const NAMES = ['_alertOptIn', '_playDisconnectBeep', 'showDisconnectAlert', 'dismissDisconnectAlert', '_checkLowBitrate',++ 'setRtmpStatusUI', 'stopRtmpStatusPoll', 'updateBroadcastStatus'];++const SRC = NAMES.map((n) => extract(bc, n)).join('\n');++++function page({ mode = 'browser', stored = {} } = {}) {++ const dom = fakeDom(mode);++ const beeps = [];++ const toasts = [];++ const fixtures = {++ document: dom.document,++ localStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = v; } },++ _playAlertBeep: (hz) => beeps.push(hz),++ toast: (msg, kind) => toasts.push([msg, kind]),++ getTargetVideoBitrate: () => 2500 * 1000,++ getActiveStreamState: () => ({ streamData: { id: 5, protocol: mode === 'browser' ? 'webrtc' : 'rtmp' } }),++ startRtmpPreview: () => {}, stopRtmpPreview: () => {},++ clearInterval: () => {},++ LOW_BITRATE_RATIO: constant('LOW_BITRATE_RATIO'),++ LOW_BITRATE_SAMPLES: constant('LOW_BITRATE_SAMPLES'),++ _disconnectAlertShown: false, _rtmpWasReceiving: false, _rtmpFeedLost: false, _rtmpStatusPollTimer: null,++ };++ const scope = new Proxy(fixtures, {++ has: (t, k) => typeof k === 'string',++ get: (t, k) => (k === Symbol.unscopables ? undefined : (k in t ? t[k] : globalThis[k])),++ set: (t, k, v) => { t[k] = v; return true; },++ });++ // eslint-disable-next-line no-new-func++ const fns = new Function('scope', `with (scope) { ${SRC}\n return { ${NAMES.join(', ')} }; }`)(scope);++ // The opt-ins start from what this browser stored, exactly as the page's declarations do.++ fixtures._disconnectAudioEnabled = fns._alertOptIn('bc-disconnect-audio');++ fixtures._lowBitrateAlertEnabled = fns._alertOptIn('bc-lowbitrate-audio');++ const banner = () => fixtures.document.getElementById('bc-disconnect-alert') || dom.inserted.length;++ return { fns, fixtures, dom, beeps, toasts, banner };++}++++// ── the declarations: off by default, from this browser's storage ──++assert.match(bc, /let _disconnectAudioEnabled = _alertOptIn\('bc-disconnect-audio'\);/);++assert.match(bc, /let _lowBitrateAlertEnabled = _alertOptIn\('bc-lowbitrate-audio'\);/);++let p = page();++assert.deepStrictEqual([p.fixtures._disconnectAudioEnabled, p.fixtures._lowBitrateAlertEnabled], [false, false], 'both off by default');++p = page({ stored: { 'bc-disconnect-audio': '1', 'bc-lowbitrate-audio': '1' } });++assert.deepStrictEqual([p.fixtures._disconnectAudioEnabled, p.fixtures._lowBitrateAlertEnabled], [true, true], 'an opt-in survives a reload');++console.log('OK both alerts are opt-in, remembered in this browser');++++// ── browser broadcast disconnects ──++p = page();++p.fns.updateBroadcastStatus('disconnected');++assert.ok(p.fixtures._disconnectAlertShown, 'the banner shows without the opt-in');++assert.deepStrictEqual(p.dom.inserted[0], ['bc-disconnect-alert', 'bc-video-container'], 'above the browser preview');++assert.deepStrictEqual(p.beeps, [], 'but no sound');++p.fns.updateBroadcastStatus('connected');++assert.ok(!p.fixtures._disconnectAlertShown, 'reconnecting clears it');++++p = page({ stored: { 'bc-disconnect-audio': '1' } });++p.fns.updateBroadcastStatus('failed');++p.fns.updateBroadcastStatus('disconnected');++assert.deepStrictEqual(p.beeps, [800], 'opted in: one disconnect beep per drop, not one per status update');++p.fns.updateBroadcastStatus('connected');++p.fns.updateBroadcastStatus('disconnected');++assert.deepStrictEqual(p.beeps, [800, 800], 'and again on the next drop');++console.log('OK a browser broadcast\'s drop: banner always, beep when opted in');++++// ── encoder (RTMP) feed ──++p = page({ mode: 'rtmp', stored: { 'bc-disconnect-audio': '1' } });++p.fns.setRtmpStatusUI(false);++assert.ok(!p.fixtures._disconnectAlertShown, 'waiting for the encoder is not a disconnect');++p.fns.setRtmpStatusUI(true, new Date().toISOString());++assert.ok(!p.fixtures._disconnectAlertShown);++p.fns.setRtmpStatusUI(false);++assert.ok(p.fixtures._disconnectAlertShown, 'the feed stopping after it was received is');++assert.deepStrictEqual(p.beeps, [800], 'with the same opt-in beep');++assert.deepStrictEqual(p.dom.inserted[0], ['bc-disconnect-alert', 'bc-rtmp-status'], 'shown above the RTMP feed status (the browser section is hidden)');++p.fns.setRtmpStatusUI(false);++assert.deepStrictEqual(p.beeps, [800], 'polls while it stays down do not beep again');++p.fns.setRtmpStatusUI(true, new Date().toISOString());++assert.ok(!p.fixtures._disconnectAlertShown, 'the feed coming back clears it');++p.fns.setRtmpStatusUI(false);++p.fns.stopRtmpStatusPoll();++assert.ok(!p.fixtures._disconnectAlertShown, 'ending the stream (or leaving the page) clears it');++p.fns.setRtmpStatusUI(false);++assert.deepStrictEqual(p.beeps, [800, 800], 'and a new poll starts from "waiting", not from "lost"');++console.log('OK an encoder\'s RTMP feed stopping raises the same alert');++++// ── low bitrate ──++const target = 2500;++const low = Math.floor(target * constant('LOW_BITRATE_RATIO')) - 1;++p = page();++let ss = {};++for (let i = 0; i < 6; i++) p.fns._checkLowBitrate(ss, low, 'connected');++assert.deepStrictEqual([p.toasts.length, p.beeps.length], [0, 0], 'not opted in: nothing');++++p = page({ stored: { 'bc-lowbitrate-audio': '1' } });++ss = {};++p.fns._checkLowBitrate(ss, low, 'connected');++p.fns._checkLowBitrate(ss, low, 'connected');++assert.strictEqual(p.toasts.length, 0, 'one or two low samples are a blip');++p.fns._checkLowBitrate(ss, low, 'connected');++assert.strictEqual(p.toasts.length, 1, 'three in a row (≈18 s) warn');++assert.match(p.toasts[0][0], new RegExp(`Low bitrate: ${low} kbps of ${target} kbps`));++assert.deepStrictEqual(p.beeps, [440], 'with a lower beep than a disconnect');++for (let i = 0; i < 5; i++) p.fns._checkLowBitrate(ss, low, 'connected');++assert.strictEqual(p.toasts.length, 1, 'once per episode');++p.fns._checkLowBitrate(ss, 2400, 'connected');++for (let i = 0; i < 3; i++) p.fns._checkLowBitrate(ss, low, 'connected');++assert.strictEqual(p.toasts.length, 2, 'a new episode after it recovered warns again');++++p = page({ stored: { 'bc-lowbitrate-audio': '1' } });++ss = {};++for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, 0, 'connected'); // still measuring++for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, low, 'checking'); // (re)connecting++for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, Math.ceil(target * constant('LOW_BITRATE_RATIO')) + 1, 'connected');++assert.strictEqual(p.toasts.length, 0, 'no warning while measuring, while not connected, or at 30% and above');++console.log('OK low bitrate: opt-in, after ~18 s under 30% of target, once per episode');++++// ── wiring ──++assert.match(bc, /_checkLowBitrate\(ss, hasStats \? bitrateKbps : 0, connState\);\s*\} finally \{/, 'every stats sample of the active stream is checked');++assert.match(fragment, /id="bc-disconnectAudio" onchange="_disconnectAudioEnabled = this\.checked; try \{ localStorage\.setItem\('bc-disconnect-audio'/);++assert.match(fragment, /id="bc-ext-disconnectAudio" onchange="_disconnectAudioEnabled = this\.checked; localStorage\.setItem\('bc-disconnect-audio'/);++assert.match(fragment, /id="bc-lowBitrateAudio" onchange="_lowBitrateAlertEnabled = this\.checked; try \{ localStorage\.setItem\('bc-lowbitrate-audio'/);++const sync = extract(bc, 'syncSettingsUI');++for (const id of ['bc-disconnectAudio', 'bc-ext-disconnectAudio', 'bc-lowBitrateAudio']) {++ assert.ok(sync.includes(`setCheck('${id}'`), `the settings sync shows the stored preference in #${id}`);++}++console.log('OK toggles in both settings panels, restored on load');++++console.log('\n✅ broadcaster alert checks passed');++process.exit(0);
@@ -51,8 +51,12 @@ The broadcast page includes an embedded chat sidebar with:
@@ -51,8 +51,12 @@ The broadcast page includes an embedded chat sidebar with:
- Mobile: floating action button to toggle chat overlay
- Mobile: floating action button to toggle chat overlay
### Disconnect Handling
### Disconnect Handling
-- Visual disconnect alert banner when connection drops
-- **Audible alert** — opt-in audio beep on disconnect (enable in Audio settings → "Disconnect Sound Alert")
+- Visual disconnect alert banner when the connection drops (a browser broadcast's connection, or the
+ encoder's RTMP feed stopping after it was received)
+- **Audible alert** — opt-in audio beep on disconnect (enable in Audio settings → "Disconnect Sound Alert",
+ or Alerts → "Disconnect Audio" in the encoder settings; one preference, kept in this browser)
+- **Low-bitrate alert** — opt-in (Audio settings → "Low Bitrate Alert"): when a browser broadcast's upload
+ stays under 30% of its target bitrate for about 18 seconds, a warning and a lower beep, once until it recovers
- Automatic reconnection with exponential backoff (3s → 30s max)
- Automatic reconnection with exponential backoff (3s → 30s max)
- Connection status indicator (protocol name when connected, status when not)
- Connection status indicator (protocol name when connected, status when not)
@@ -906,6 +906,10 @@ WantedBy=multi-user.target</code>
@@ -906,6 +906,10 @@ WantedBy=multi-user.target</code>
<span class="bc-setting-label">Disconnect Sound Alert</span>
<span class="bc-setting-label">Disconnect Sound Alert</span>
<div class="bc-setting-control"><label class="bc-toggle"><input type="checkbox" id="bc-disconnectAudio" onchange="_disconnectAudioEnabled = this.checked; try { localStorage.setItem('bc-disconnect-audio', this.checked ? '1' : '0'); } catch{}"><span class="bc-toggle-slider"></span></label></div>
<div class="bc-setting-control"><label class="bc-toggle"><input type="checkbox" id="bc-disconnectAudio" onchange="_disconnectAudioEnabled = this.checked; try { localStorage.setItem('bc-disconnect-audio', this.checked ? '1' : '0'); } catch{}"><span class="bc-toggle-slider"></span></label></div>
</div>
</div>
+ <div class="bc-setting-row" title="Warn (with a lower beep) when your upload stays under 30% of the target bitrate for about 18 seconds">
+ <span class="bc-setting-label">Low Bitrate Alert</span>
+ <div class="bc-setting-control"><label class="bc-toggle"><input type="checkbox" id="bc-lowBitrateAudio" onchange="_lowBitrateAlertEnabled = this.checked; try { localStorage.setItem('bc-lowbitrate-audio', this.checked ? '1' : '0'); } catch{}"><span class="bc-toggle-slider"></span></label></div>
+ </div>
</div>
</div>
<!-- Interactive Controls Profile (live) -->
<!-- Interactive Controls Profile (live) -->
@@ -1850,6 +1850,10 @@ async function showRTMPInstructions(stream) {
@@ -1850,6 +1850,10 @@ async function showRTMPInstructions(stream) {
startRtmpStatusPoll(stream.id);
startRtmpStatusPoll(stream.id);
}
}
+// Was the encoder's feed received at the last poll, and has it been lost since? Losing it is a disconnect.
+let _rtmpWasReceiving = false;
+let _rtmpFeedLost = false;
+
function startRtmpStatusPoll(streamId) {
function startRtmpStatusPoll(streamId) {
stopRtmpStatusPoll();
stopRtmpStatusPoll();
setRtmpStatusUI(false);
setRtmpStatusUI(false);
@@ -1865,9 +1869,17 @@ function startRtmpStatusPoll(streamId) {
@@ -1865,9 +1869,17 @@ function startRtmpStatusPoll(streamId) {
function stopRtmpStatusPoll() {
function stopRtmpStatusPoll() {
if (_rtmpStatusPollTimer) { clearInterval(_rtmpStatusPollTimer); _rtmpStatusPollTimer = null; }
if (_rtmpStatusPollTimer) { clearInterval(_rtmpStatusPollTimer); _rtmpStatusPollTimer = null; }
+ _rtmpWasReceiving = false;
+ if (_rtmpFeedLost) { _rtmpFeedLost = false; dismissDisconnectAlert(); }
}
}
function setRtmpStatusUI(receiving, connectedAt) {
function setRtmpStatusUI(receiving, connectedAt) {
+ // The encoder's feed stopping after it was received is a disconnect: the same banner and the same
+ // opt-in sound as a browser broadcast. Only browser connection states raised it before, so the
+ // encoder settings' "Disconnect Audio" toggle never sounded.
+ if (!receiving && _rtmpWasReceiving) { _rtmpFeedLost = true; showDisconnectAlert('the encoder\'s RTMP feed stopped'); }
+ else if (receiving && _rtmpFeedLost) { _rtmpFeedLost = false; dismissDisconnectAlert(); }
+ _rtmpWasReceiving = !!receiving;
const wrap = document.getElementById('bc-rtmp-status');
const wrap = document.getElementById('bc-rtmp-status');
if (!wrap) return;
if (!wrap) return;
const spinner = document.getElementById('bc-rtmp-status-spinner');
const spinner = document.getElementById('bc-rtmp-status-spinner');
@@ -2120,11 +2132,16 @@ function syncSettingsUI() {
@@ -2120,11 +2132,16 @@ function syncSettingsUI() {
setCheck('bc-autoGain', s.autoGain); setCheck('bc-echoCancellation', s.echoCancellation);
setCheck('bc-autoGain', s.autoGain); setCheck('bc-echoCancellation', s.echoCancellation);
setCheck('bc-noiseSuppression', s.noiseSuppression); setCheck('bc-manualGainEnabled', s.manualGainEnabled);
setCheck('bc-noiseSuppression', s.noiseSuppression); setCheck('bc-manualGainEnabled', s.manualGainEnabled);
setCheck('bc-force48kSampleRate', s.force48kSampleRate);
setCheck('bc-force48kSampleRate', s.force48kSampleRate);
- // Restore disconnect audio alert from localStorage (not synced to server — local preference)
+ // Restore the opt-in alerts from localStorage (not synced to server — local preferences). Both
+ // disconnect toggles (browser and encoder settings) show the one preference.
try {
try {
const discAudio = localStorage.getItem('bc-disconnect-audio') === '1';
const discAudio = localStorage.getItem('bc-disconnect-audio') === '1';
_disconnectAudioEnabled = discAudio;
_disconnectAudioEnabled = discAudio;
setCheck('bc-disconnectAudio', discAudio);
setCheck('bc-disconnectAudio', discAudio);
+ setCheck('bc-ext-disconnectAudio', discAudio);
+ const lowBitrate = localStorage.getItem('bc-lowbitrate-audio') === '1';
+ _lowBitrateAlertEnabled = lowBitrate;
+ setCheck('bc-lowBitrateAudio', lowBitrate);
} catch {}
} catch {}
setVal('bc-broadcastRes', s.broadcastRes); setVal('bc-broadcastFps', s.broadcastFps);
setVal('bc-broadcastRes', s.broadcastRes); setVal('bc-broadcastFps', s.broadcastFps);
setVal('bc-broadcastCodec', s.broadcastCodec); setVal('bc-broadcastBps', s.broadcastBps);
setVal('bc-broadcastCodec', s.broadcastCodec); setVal('bc-broadcastBps', s.broadcastBps);
@@ -2487,6 +2504,7 @@ function startGlobalDisplayTimers() {
@@ -2487,6 +2504,7 @@ function startGlobalDisplayTimers() {
healthEl.textContent = health;
healthEl.textContent = health;
healthEl.style.color = color;
healthEl.style.color = color;
}
}
+ _checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);
} finally {
} finally {
if (ss) ss.statsPollPending = false;
if (ss) ss.statsPollPending = false;
}
}
@@ -6589,18 +6607,19 @@ function stopRtmpPreview() {
@@ -6589,18 +6607,19 @@ function stopRtmpPreview() {
/* ── Disconnect Alert Banner + Audible Alert ─────────────────── */
/* ── Disconnect Alert Banner + Audible Alert ─────────────────── */
let _disconnectAlertShown = false;
let _disconnectAlertShown = false;
-let _disconnectAudioEnabled = false; // Default off — user must opt in
+/** A stored opt-in (this browser only); off unless the broadcaster turned it on. */
+function _alertOptIn(key) { try { return localStorage.getItem(key) === '1'; } catch { return false; } }
+let _disconnectAudioEnabled = _alertOptIn('bc-disconnect-audio'); // Default off — user must opt in
-/** Play an audible alert beep using Web Audio API */
-function _playDisconnectBeep() {
- if (!_disconnectAudioEnabled) return;
+/** Two short beeps through Web Audio (the pitch tells the alerts apart). */
+function _playAlertBeep(frequency) {
try {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const gain = ctx.createGain();
osc.connect(gain);
osc.connect(gain);
gain.connect(ctx.destination);
gain.connect(ctx.destination);
- osc.frequency.value = 800;
+ osc.frequency.value = frequency;
osc.type = 'square';
osc.type = 'square';
gain.gain.value = 0.3;
gain.gain.value = 0.3;
osc.start();
osc.start();
@@ -6614,6 +6633,12 @@ function _playDisconnectBeep() {
@@ -6614,6 +6633,12 @@ function _playDisconnectBeep() {
} catch { /* Web Audio not available */ }
} catch { /* Web Audio not available */ }
}
}
+/** Play the disconnect beep, if the broadcaster opted in */
+function _playDisconnectBeep() {
+ if (!_disconnectAudioEnabled) return;
+ _playAlertBeep(800);
+}
+
function showDisconnectAlert(reason) {
function showDisconnectAlert(reason) {
if (_disconnectAlertShown) return;
if (_disconnectAlertShown) return;
_disconnectAlertShown = true;
_disconnectAlertShown = true;
@@ -6622,14 +6647,41 @@ function showDisconnectAlert(reason) {
@@ -6622,14 +6647,41 @@ function showDisconnectAlert(reason) {
banner = document.createElement('div');
banner = document.createElement('div');
banner.id = 'bc-disconnect-alert';
banner.id = 'bc-disconnect-alert';
banner.className = 'bc-disconnect-alert';
banner.className = 'bc-disconnect-alert';
- const container = document.querySelector('.bc-video-container') || document.getElementById('bc-live-section');
- if (container) container.parentElement.insertBefore(banner, container);
}
}
+ // Above the browser preview, or above the RTMP feed status when the stream comes from an encoder
+ // (the browser section is hidden then, and a banner inside it was never seen).
+ const browserBox = document.getElementById('bc-browser-broadcast');
+ const anchor = (browserBox && browserBox.style.display !== 'none' && document.querySelector('.bc-video-container'))
+ || document.getElementById('bc-rtmp-status') || document.getElementById('bc-live-section');
+ if (anchor && anchor.previousElementSibling !== banner) anchor.parentElement.insertBefore(banner, anchor);
banner.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> <span>Connection lost — ${reason || 'reconnecting...'}</span><button class="btn btn-small btn-outline" onclick="dismissDisconnectAlert()"><i class="fa-solid fa-xmark"></i></button>`;
banner.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> <span>Connection lost — ${reason || 'reconnecting...'}</span><button class="btn btn-small btn-outline" onclick="dismissDisconnectAlert()"><i class="fa-solid fa-xmark"></i></button>`;
banner.style.display = '';
banner.style.display = '';
_playDisconnectBeep();
_playDisconnectBeep();
}
}
+/* ── Low-Bitrate Alert (opt-in) ──────────────────────────────── */
+let _lowBitrateAlertEnabled = _alertOptIn('bc-lowbitrate-audio'); // Default off — user must opt in
+const LOW_BITRATE_RATIO = 0.3; // measured upload under 30% of the target bitrate…
+const LOW_BITRATE_SAMPLES = 3; // …in three stats samples in a row (6 s apart, so ≈18 s)
+
+/**
+ * Called with every stats sample of the active browser stream. A connected stream whose upload stays
+ * well under its target warns once per episode (toast + a lower beep), and only when the broadcaster
+ * opted in: a still scene can legitimately send little, so this is never on by default.
+ */
+function _checkLowBitrate(ss, bitrateKbps, connState) {
+ if (!ss) return;
+ const targetKbps = getTargetVideoBitrate() / 1000;
+ const connected = connState === 'connected' || connState === 'completed';
+ const low = connected && bitrateKbps > 0 && bitrateKbps < targetKbps * LOW_BITRATE_RATIO;
+ if (!low) { ss._lowBitrateSamples = 0; ss._lowBitrateAlerted = false; return; }
+ ss._lowBitrateSamples = (ss._lowBitrateSamples || 0) + 1;
+ if (ss._lowBitrateSamples < LOW_BITRATE_SAMPLES || ss._lowBitrateAlerted || !_lowBitrateAlertEnabled) return;
+ ss._lowBitrateAlerted = true;
+ toast(`Low bitrate: ${bitrateKbps} kbps of ${Math.round(targetKbps)} kbps — viewers may see stutter or blur`, 'warning');
+ _playAlertBeep(440);
+}
+
function dismissDisconnectAlert() {
function dismissDisconnectAlert() {
_disconnectAlertShown = false;
_disconnectAlertShown = false;
const banner = document.getElementById('bc-disconnect-alert');
const banner = document.getElementById('bc-disconnect-alert');
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+/**
+ * Legacy parity (roadmap D20): opt-in audible disconnect and low-bitrate alerts for the broadcaster.
+ *
+ * Runs the broadcast page's own alert functions (public/js/broadcast.js) against a small fake DOM:
+ * - a browser broadcast's connection dropping shows the banner; the beep sounds only when opted in;
+ * - an encoder's RTMP feed stopping after it was received does the same (before, only browser
+ * connection states raised it, so the encoder settings' "Disconnect Audio" toggle never sounded,
+ * and the banner was put inside the hidden browser section);
+ * - a connected browser broadcast whose upload stays under 30% of its target for three stats samples
+ * warns once per episode, with a lower beep, only when opted in (this alert was missing);
+ * - both preferences are off by default, kept in this browser, and restored into every toggle.
+ *
+ * node test/broadcast-alerts.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+const bc = read('public/js/broadcast.js');
+const fragment = read('public/fragments/broadcast.html');
+
+/** The source of a top-level function declaration, braces matched. */
+function extract(src, name) {
+ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
+ assert.ok(m, `${name}() must exist`);
+ let i = src.indexOf('{', m.index);
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
+ }
+ throw new Error(`unbalanced ${name}`);
+}
+const constant = (name) => Number(new RegExp(`const ${name} = ([\\d.]+);`).exec(bc)[1]);
+
+/** A tiny DOM: elements by id, each with style/className/textContent and an insertBefore log. */
+function fakeDom(mode) {
+ const els = {};
+ const inserted = [];
+ const parent = { insertBefore: (node, before) => inserted.push([node.id, before.id]) };
+ const el = (id, extra = {}) => (els[id] = { id, style: {}, className: '', textContent: '', innerHTML: '', parentElement: parent, previousElementSibling: null, ...extra });
+ for (const id of ['bc-rtmp-status', 'bc-rtmp-status-spinner', 'bc-rtmp-status-ok', 'bc-rtmp-status-label', 'bc-rtmp-status-detail', 'bc-live-section', 'bc-connection-status']) el(id);
+ el('bc-browser-broadcast', { style: { display: mode === 'browser' ? '' : 'none' } });
+ const videoBox = { id: 'bc-video-container', parentElement: parent, previousElementSibling: null };
+ const document = {
+ getElementById: (id) => els[id] || null,
+ createElement: () => ({ id: '', className: '', style: {}, innerHTML: '' }),
+ querySelector: (sel) => (sel === '.bc-video-container' ? videoBox : null),
+ };
+ return { els, inserted, document };
+}
+
+const NAMES = ['_alertOptIn', '_playDisconnectBeep', 'showDisconnectAlert', 'dismissDisconnectAlert', '_checkLowBitrate',
+ 'setRtmpStatusUI', 'stopRtmpStatusPoll', 'updateBroadcastStatus'];
+const SRC = NAMES.map((n) => extract(bc, n)).join('\n');
+
+function page({ mode = 'browser', stored = {} } = {}) {
+ const dom = fakeDom(mode);
+ const beeps = [];
+ const toasts = [];
+ const fixtures = {
+ document: dom.document,
+ localStorage: { getItem: (k) => (k in stored ? stored[k] : null), setItem: (k, v) => { stored[k] = v; } },
+ _playAlertBeep: (hz) => beeps.push(hz),
+ toast: (msg, kind) => toasts.push([msg, kind]),
+ getTargetVideoBitrate: () => 2500 * 1000,
+ getActiveStreamState: () => ({ streamData: { id: 5, protocol: mode === 'browser' ? 'webrtc' : 'rtmp' } }),
+ startRtmpPreview: () => {}, stopRtmpPreview: () => {},
+ clearInterval: () => {},
+ LOW_BITRATE_RATIO: constant('LOW_BITRATE_RATIO'),
+ LOW_BITRATE_SAMPLES: constant('LOW_BITRATE_SAMPLES'),
+ _disconnectAlertShown: false, _rtmpWasReceiving: false, _rtmpFeedLost: false, _rtmpStatusPollTimer: null,
+ };
+ const scope = new Proxy(fixtures, {
+ has: (t, k) => typeof k === 'string',
+ get: (t, k) => (k === Symbol.unscopables ? undefined : (k in t ? t[k] : globalThis[k])),
+ set: (t, k, v) => { t[k] = v; return true; },
+ });
+ // eslint-disable-next-line no-new-func
+ const fns = new Function('scope', `with (scope) { ${SRC}\n return { ${NAMES.join(', ')} }; }`)(scope);
+ // The opt-ins start from what this browser stored, exactly as the page's declarations do.
+ fixtures._disconnectAudioEnabled = fns._alertOptIn('bc-disconnect-audio');
+ fixtures._lowBitrateAlertEnabled = fns._alertOptIn('bc-lowbitrate-audio');
+ const banner = () => fixtures.document.getElementById('bc-disconnect-alert') || dom.inserted.length;
+ return { fns, fixtures, dom, beeps, toasts, banner };
+}
+
+// ── the declarations: off by default, from this browser's storage ──
+assert.match(bc, /let _disconnectAudioEnabled = _alertOptIn\('bc-disconnect-audio'\);/);
+assert.match(bc, /let _lowBitrateAlertEnabled = _alertOptIn\('bc-lowbitrate-audio'\);/);
+let p = page();
+assert.deepStrictEqual([p.fixtures._disconnectAudioEnabled, p.fixtures._lowBitrateAlertEnabled], [false, false], 'both off by default');
+p = page({ stored: { 'bc-disconnect-audio': '1', 'bc-lowbitrate-audio': '1' } });
+assert.deepStrictEqual([p.fixtures._disconnectAudioEnabled, p.fixtures._lowBitrateAlertEnabled], [true, true], 'an opt-in survives a reload');
+console.log('OK both alerts are opt-in, remembered in this browser');
+
+// ── browser broadcast disconnects ──
+p = page();
+p.fns.updateBroadcastStatus('disconnected');
+assert.ok(p.fixtures._disconnectAlertShown, 'the banner shows without the opt-in');
+assert.deepStrictEqual(p.dom.inserted[0], ['bc-disconnect-alert', 'bc-video-container'], 'above the browser preview');
+assert.deepStrictEqual(p.beeps, [], 'but no sound');
+p.fns.updateBroadcastStatus('connected');
+assert.ok(!p.fixtures._disconnectAlertShown, 'reconnecting clears it');
+
+p = page({ stored: { 'bc-disconnect-audio': '1' } });
+p.fns.updateBroadcastStatus('failed');
+p.fns.updateBroadcastStatus('disconnected');
+assert.deepStrictEqual(p.beeps, [800], 'opted in: one disconnect beep per drop, not one per status update');
+p.fns.updateBroadcastStatus('connected');
+p.fns.updateBroadcastStatus('disconnected');
+assert.deepStrictEqual(p.beeps, [800, 800], 'and again on the next drop');
+console.log('OK a browser broadcast\'s drop: banner always, beep when opted in');
+
+// ── encoder (RTMP) feed ──
+p = page({ mode: 'rtmp', stored: { 'bc-disconnect-audio': '1' } });
+p.fns.setRtmpStatusUI(false);
+assert.ok(!p.fixtures._disconnectAlertShown, 'waiting for the encoder is not a disconnect');
+p.fns.setRtmpStatusUI(true, new Date().toISOString());
+assert.ok(!p.fixtures._disconnectAlertShown);
+p.fns.setRtmpStatusUI(false);
+assert.ok(p.fixtures._disconnectAlertShown, 'the feed stopping after it was received is');
+assert.deepStrictEqual(p.beeps, [800], 'with the same opt-in beep');
+assert.deepStrictEqual(p.dom.inserted[0], ['bc-disconnect-alert', 'bc-rtmp-status'], 'shown above the RTMP feed status (the browser section is hidden)');
+p.fns.setRtmpStatusUI(false);
+assert.deepStrictEqual(p.beeps, [800], 'polls while it stays down do not beep again');
+p.fns.setRtmpStatusUI(true, new Date().toISOString());
+assert.ok(!p.fixtures._disconnectAlertShown, 'the feed coming back clears it');
+p.fns.setRtmpStatusUI(false);
+p.fns.stopRtmpStatusPoll();
+assert.ok(!p.fixtures._disconnectAlertShown, 'ending the stream (or leaving the page) clears it');
+p.fns.setRtmpStatusUI(false);
+assert.deepStrictEqual(p.beeps, [800, 800], 'and a new poll starts from "waiting", not from "lost"');
+console.log('OK an encoder\'s RTMP feed stopping raises the same alert');
+
+// ── low bitrate ──
+const target = 2500;
+const low = Math.floor(target * constant('LOW_BITRATE_RATIO')) - 1;
+p = page();
+let ss = {};
+for (let i = 0; i < 6; i++) p.fns._checkLowBitrate(ss, low, 'connected');
+assert.deepStrictEqual([p.toasts.length, p.beeps.length], [0, 0], 'not opted in: nothing');
+
+p = page({ stored: { 'bc-lowbitrate-audio': '1' } });
+ss = {};
+p.fns._checkLowBitrate(ss, low, 'connected');
+p.fns._checkLowBitrate(ss, low, 'connected');
+assert.strictEqual(p.toasts.length, 0, 'one or two low samples are a blip');
+p.fns._checkLowBitrate(ss, low, 'connected');
+assert.strictEqual(p.toasts.length, 1, 'three in a row (≈18 s) warn');
+assert.match(p.toasts[0][0], new RegExp(`Low bitrate: ${low} kbps of ${target} kbps`));
+assert.deepStrictEqual(p.beeps, [440], 'with a lower beep than a disconnect');
+for (let i = 0; i < 5; i++) p.fns._checkLowBitrate(ss, low, 'connected');
+assert.strictEqual(p.toasts.length, 1, 'once per episode');
+p.fns._checkLowBitrate(ss, 2400, 'connected');
+for (let i = 0; i < 3; i++) p.fns._checkLowBitrate(ss, low, 'connected');
+assert.strictEqual(p.toasts.length, 2, 'a new episode after it recovered warns again');
+
+p = page({ stored: { 'bc-lowbitrate-audio': '1' } });
+ss = {};
+for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, 0, 'connected'); // still measuring
+for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, low, 'checking'); // (re)connecting
+for (let i = 0; i < 4; i++) p.fns._checkLowBitrate(ss, Math.ceil(target * constant('LOW_BITRATE_RATIO')) + 1, 'connected');
+assert.strictEqual(p.toasts.length, 0, 'no warning while measuring, while not connected, or at 30% and above');
+console.log('OK low bitrate: opt-in, after ~18 s under 30% of target, once per episode');
+
+// ── wiring ──
+assert.match(bc, /_checkLowBitrate\(ss, hasStats \? bitrateKbps : 0, connState\);\s*\} finally \{/, 'every stats sample of the active stream is checked');
+assert.match(fragment, /id="bc-disconnectAudio" onchange="_disconnectAudioEnabled = this\.checked; try \{ localStorage\.setItem\('bc-disconnect-audio'/);
+assert.match(fragment, /id="bc-ext-disconnectAudio" onchange="_disconnectAudioEnabled = this\.checked; localStorage\.setItem\('bc-disconnect-audio'/);
+assert.match(fragment, /id="bc-lowBitrateAudio" onchange="_lowBitrateAlertEnabled = this\.checked; try \{ localStorage\.setItem\('bc-lowbitrate-audio'/);
+const sync = extract(bc, 'syncSettingsUI');
+for (const id of ['bc-disconnectAudio', 'bc-ext-disconnectAudio', 'bc-lowBitrateAudio']) {
+ assert.ok(sync.includes(`setCheck('${id}'`), `the settings sync shows the stored preference in #${id}`);
+}
+console.log('OK toggles in both settings panels, restored on load');
+
+console.log('\n✅ broadcaster alert checks passed');
+process.exit(0);