openvibers/openvibe.live Commits

Broadcast: explain permissions before asking, and preview the mic before going live

OP OpenVibers committed 19 Aug 2026 · 98114e2284 · 6 files, +681 −42

Three related gaps in the browser capture setup:

- Permission prompts arrived unannounced. populateDeviceLists() fired one
  combined {audio, video} getUserMedia with no explanation, which is also
  all-or-nothing: denying the camera cost the microphone too, leaving the
  streamer silent with no clue why. Camera and mic are now requested
  separately, each behind a card that says what is being asked and why, shows
  where the browser bubble will appear, and states that nothing is captured
  until Go Live. A previously-blocked device gets per-browser recovery steps
  instead of a prompt the browser will silently refuse to show.

- Device menus read "Default". enumerateDevices() returns entries with empty
  labels AND empty deviceIds until the page holds a grant, so a list built
  beforehand can only show placeholders that capture the OS default. Every
  grant now re-enumerates and rebuilds the list rather than trying to relabel
  in place.

- There was no way to tell whether a mic worked before going live. The setup
  panel now runs the SELECTED device through an AnalyserNode and paints its
  level, with "no sound yet" / "very loud" feedback. It opens the device with
  an exact deviceId constraint on purpose: a meter fed by the OS default would
  have shown a healthy signal for exactly the bug this is meant to catch. The
  device is released as soon as the mic toggle goes off.

Also adds a "What gets captured" panel listing screen, mic, system audio and
camera with their resolved device names, and warns where desktop audio is
restricted (Safari cannot, Firefox and Linux need a tab rather than a window).

Co-Authored-By: Claude Opus 5 <[email protected]>

Changes

ddiff --git a/public/css/broadcast.css b/public/css/broadcast.css
iindex 49fc3e0c..1989279e 100644
---- a/public/css/broadcast.css
++++ b/public/css/broadcast.css
@@@ -4041,3 +4041,114 @@
border-radius: 3px;
font-size: 0.75rem;
}
++
++/* ── Media setup: permission explainer, mic meter, capture summary ──
++ Paired with broadcast-media-setup.js. The explainer deliberately mimics the
++ browser's own permission bubble so the streamer recognises what to click
++ when the real one appears a moment later. */
++
++.bc-perm-overlay {
++ position: fixed; inset: 0; z-index: 4000;
++ background: rgba(6, 8, 16, 0.72);
++ backdrop-filter: blur(4px);
++ display: flex; align-items: center; justify-content: center;
++ padding: 20px; animation: bcPermIn .14s ease-out;
++}
++@keyframes bcPermIn { from { opacity: 0 } to { opacity: 1 } }
++
++.bc-perm-card {
++ width: min(460px, 100%);
++ background: var(--bg-card, #14161f);
++ border: 1px solid var(--border, rgba(255,255,255,.10));
++ border-radius: 14px; padding: 20px;
++ box-shadow: 0 18px 50px rgba(0,0,0,.5);
++}
++.bc-perm-head { display: flex; gap: 14px; align-items: flex-start; margin-bottom: 16px; }
++.bc-perm-head h3 { margin: 0 0 4px; font-size: 1.05rem; }
++.bc-perm-head p { margin: 0; color: var(--text-muted, #9aa3b2); font-size: .88rem; line-height: 1.45; }
++.bc-perm-icon {
++ flex-shrink: 0; width: 42px; height: 42px; border-radius: 11px;
++ display: grid; place-items: center; font-size: 1.05rem; color: #fff;
++ background: linear-gradient(135deg, #8b5cf6, #6d28d9);
++}
++.bc-perm-icon.is-blocked { background: linear-gradient(135deg, #ef4444, #b91c1c); }
++
++/* Mock of the browser's permission bubble */
++.bc-perm-mock {
++ border: 1px solid var(--border, rgba(255,255,255,.10));
++ border-radius: 10px; overflow: hidden; margin-bottom: 12px;
++ background: rgba(255,255,255,.03);
++}
++.bc-perm-mock-bar {
++ display: flex; align-items: center; gap: 7px;
++ padding: 7px 10px; font-size: .78rem; color: var(--text-muted, #9aa3b2);
++ background: rgba(255,255,255,.05); border-bottom: 1px solid var(--border, rgba(255,255,255,.08));
++}
++.bc-perm-mock-pop { padding: 12px 12px 13px; }
++.bc-perm-mock-pop p { margin: 0 0 10px; font-size: .86rem; display: flex; align-items: center; gap: 8px; }
++.bc-perm-mock-btns { display: flex; gap: 8px; justify-content: flex-end; }
++.bc-perm-mock-btns span { font-size: .78rem; padding: 5px 13px; border-radius: 6px; }
++.bc-perm-mock-btns .ghost { border: 1px solid var(--border, rgba(255,255,255,.14)); color: var(--text-muted, #9aa3b2); }
++.bc-perm-mock-btns .primary {
++ background: #3b82f6; color: #fff; font-weight: 600;
++ box-shadow: 0 0 0 3px rgba(59,130,246,.28);
++ animation: bcPermPulse 1.7s ease-in-out infinite;
++}
++@keyframes bcPermPulse {
++ 0%,100% { box-shadow: 0 0 0 3px rgba(59,130,246,.28) }
++ 50% { box-shadow: 0 0 0 6px rgba(59,130,246,.10) }
++}
++
++.bc-perm-note {
++ margin: 7px 0 0; font-size: .79rem; line-height: 1.45;
++ color: var(--text-muted, #9aa3b2); display: flex; gap: 8px;
++}
++.bc-perm-note i { margin-top: 2px; opacity: .8; flex-shrink: 0; }
++
++.bc-perm-steps { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
++.bc-perm-step { display: flex; gap: 11px; align-items: flex-start; }
++.bc-perm-step span {
++ flex-shrink: 0; width: 21px; height: 21px; border-radius: 50%;
++ background: rgba(255,255,255,.09); display: grid; place-items: center;
++ font-size: .72rem; font-weight: 700;
++}
++.bc-perm-step p { margin: 0; font-size: .85rem; line-height: 1.45; color: var(--text-muted, #9aa3b2); }
++
++.bc-perm-actions { display: flex; gap: 9px; justify-content: flex-end; margin-top: 18px; }
++
++/* Live microphone level meter */
++.bc-mic-meter-wrap { margin-top: 7px; }
++.bc-mic-meter {
++ width: 100%; height: 22px; display: block;
++ border-radius: 5px; background: rgba(255,255,255,.05);
++}
++.bc-mic-meter-status { font-size: .76rem; color: var(--text-muted, #9aa3b2); margin-top: 4px; display: block; }
++.bc-mic-meter-status.is-good { color: #22c55e; }
++.bc-mic-meter-status.is-warn { color: #f59e0b; }
++.bc-mic-meter-status.is-bad { color: #ef4444; }
++
++/* "What will be captured" summary */
++.bc-cap-list { display: flex; flex-direction: column; gap: 6px; }
++.bc-cap-row {
++ display: flex; align-items: center; gap: 11px;
++ padding: 8px 11px; border-radius: 8px;
++ background: rgba(255,255,255,.035);
++ border: 1px solid transparent;
++}
++.bc-cap-row.is-on { border-color: rgba(34,197,94,.28); background: rgba(34,197,94,.07); }
++.bc-cap-row.is-off { opacity: .62; }
++.bc-cap-row > i { width: 17px; text-align: center; flex-shrink: 0; }
++.bc-cap-row > div { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
++.bc-cap-row strong { font-size: .84rem; font-weight: 600; }
++.bc-cap-row span { font-size: .76rem; color: var(--text-muted, #9aa3b2); }
++.bc-cap-row em {
++ font-style: normal; font-size: .7rem; font-weight: 700;
++ letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0;
++}
++.bc-cap-row.is-on em { color: #22c55e; }
++.bc-cap-row.is-off em { color: var(--text-muted, #9aa3b2); }
++.bc-cap-warn {
++ margin: -1px 0 3px; padding: 6px 11px; font-size: .76rem; line-height: 1.4;
++ color: #f59e0b; display: flex; gap: 7px; align-items: flex-start;
++}
++.bc-cap-warn i { margin-top: 2px; flex-shrink: 0; }
ddiff --git a/public/index.html b/public/index.html
iindex 2bfecb16..0f64e7b7 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5030,10 +5030,11 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<script src="/js/broadcast-state.js?v=1" defer></script>
++<script src="/js/broadcast-media-setup.js?v=1" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
--<script src="/js/broadcast-workspace.js?v=11" defer></script>
--<script src="/js/broadcast.js?v=25" defer></script>
++<script src="/js/broadcast-workspace.js?v=12" defer></script>
++<script src="/js/broadcast.js?v=26" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 971a1fd1..62a4c471 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -83,17 +83,26 @@ async function populateDeviceLists() {
const preDevices = await navigator.mediaDevices.enumerateDevices();
const alreadyHaveLabels = preDevices.some(d => d.label);
if (!alreadyHaveLabels) {
-- try {
-- tempStream = await _getUserMediaWithTimeout({ audio: true, video: true }, 8000);
-- } catch {
-- // Separate fallback for Android
++ // Ask for camera and mic SEPARATELY, each behind its own explainer, instead of
++ // firing one combined {audio,video} prompt with no warning. A combined request
++ // is also all-or-nothing: denying the camera used to cost the microphone too,
++ // which left the streamer with no audio and no clue why.
++ if (window.bcMediaSetup) {
++ await window.bcMediaSetup.request('mic').catch(() => null);
++ await window.bcMediaSetup.request('camera').catch(() => null);
++ } else {
try {
-- tempStream = new MediaStream();
-- const vs = await _getUserMediaWithTimeout({ video: true }, 6000).catch(() => null);
-- const as = await _getUserMediaWithTimeout({ audio: true }, 6000).catch(() => null);
-- if (vs) vs.getTracks().forEach(t => tempStream.addTrack(t));
-- if (as) as.getTracks().forEach(t => tempStream.addTrack(t));
-- } catch {}
++ tempStream = await _getUserMediaWithTimeout({ audio: true, video: true }, 8000);
++ } catch {
++ // Separate fallback for Android
++ try {
++ tempStream = new MediaStream();
++ const vs = await _getUserMediaWithTimeout({ video: true }, 6000).catch(() => null);
++ const as = await _getUserMediaWithTimeout({ audio: true }, 6000).catch(() => null);
++ if (vs) vs.getTracks().forEach(t => tempStream.addTrack(t));
++ if (as) as.getTracks().forEach(t => tempStream.addTrack(t));
++ } catch {}
++ }
}
}
const devices = await navigator.mediaDevices.enumerateDevices();
@@@ -105,7 +114,10 @@ async function populateDeviceLists() {
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.deviceId;
-- opt.textContent = d.label || `Device ${d.deviceId.slice(0, 8)}`;
++ // An empty label means we hold no permission for that KIND yet — the entry is
++ // a placeholder whose deviceId is blank, so selecting it captures the OS
++ // default. Re-run this after a grant rather than trying to relabel in place.
++ opt.textContent = d.label || `Device ${(d.deviceId || '').slice(0, 8) || 'unavailable'}`;
if (d.kind === 'videoinput') camSelect.appendChild(opt);
if (d.kind === 'audioinput') audioSelect.appendChild(opt.cloneNode(true));
});
ddiff --git a/public/js/broadcast-media-setup.js b/public/js/broadcast-media-setup.js
nnew file mode 100644
iindex 00000000..f46cf91a
---- /dev/null
++++ b/public/js/broadcast-media-setup.js
@@@ -0,0 +1,367 @@
++/* ───────────────────────────────────────────────────────────────
++ broadcast-media-setup.js — device permission flow, live mic
++ preview, and "what will actually be captured" summary.
++
++ Three problems this exists to solve:
++
++ 1. Permission prompts arrived with no warning. The browser bar appeared,
++ the streamer clicked something, and if they got it wrong the only
++ feedback was silence once they were already live. We now explain what is
++ about to be asked and why BEFORE triggering the prompt, and we explain
++ how to recover if it was previously denied (the browser will not re-ask
++ on its own once blocked).
++
++ 2. Device dropdowns read "Default". enumerateDevices() deliberately returns
++ entries with an EMPTY label until the page holds a permission grant, so
++ a list built before the grant can only ever show placeholders — and the
++ deviceIds it returns are blank too, so selecting one silently captures
++ the OS default. Every grant here re-enumerates and rebuilds.
++
++ 3. There was no way to tell whether a mic worked before going live. The
++ meter below runs the real selected device through an AnalyserNode so
++ "am I actually being heard" is answerable in the setup panel rather than
++ after a viewer complains.
++ ─────────────────────────────────────────────────────────────── */
++
++(function () {
++ 'use strict';
++
++ const KIND = {
++ mic: {
++ label: 'microphone',
++ icon: 'fa-solid fa-microphone',
++ permName: 'microphone',
++ constraints: { audio: true },
++ deviceKind: 'audioinput',
++ why: 'so viewers can hear you talk over your stream',
++ },
++ camera: {
++ label: 'camera',
++ icon: 'fa-solid fa-video',
++ permName: 'camera',
++ constraints: { video: true },
++ deviceKind: 'videoinput',
++ why: 'so your camera can appear as a picture-in-picture over the screen share',
++ },
++ };
++
++ /* ── Permission state ─────────────────────────────────────── */
++
++ /**
++ * 'granted' | 'denied' | 'prompt' | 'unknown'
++ * The Permissions API is not universally implemented for camera/microphone
++ * (notably older Safari), so 'unknown' is a normal answer and callers must
++ * treat it as "we will have to ask and find out".
++ */
++ async function permissionState(kind) {
++ const meta = KIND[kind];
++ if (!meta) return 'unknown';
++ try {
++ if (!navigator.permissions?.query) return 'unknown';
++ const st = await navigator.permissions.query({ name: meta.permName });
++ return st.state || 'unknown';
++ } catch { return 'unknown'; }
++ }
++
++ /** Do we already hold a grant? Labels are only populated once we do. */
++ async function hasLabels(deviceKind) {
++ try {
++ const devices = await navigator.mediaDevices.enumerateDevices();
++ return devices.some(d => d.kind === deviceKind && d.label);
++ } catch { return false; }
++ }
++
++ /** Devices of one kind, only those the browser was willing to name. */
++ async function listDevices(deviceKind) {
++ try {
++ const devices = await navigator.mediaDevices.enumerateDevices();
++ return devices.filter(d => d.kind === deviceKind && d.deviceId && d.label);
++ } catch { return []; }
++ }
++
++ /* ── Pre-flight explainer ─────────────────────────────────── */
++
++ function browserHint() {
++ const ua = navigator.userAgent;
++ if (/Firefox\//.test(ua)) return 'Firefox shows the prompt just under the address bar. Tick “Remember this decision” so it stops asking.';
++ if (/Edg\//.test(ua)) return 'Edge shows the prompt under the address bar, on the left.';
++ if (/Chrome\//.test(ua)) return 'Chrome shows the prompt under the address bar, on the left.';
++ if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Safari → Settings for This Website.';
++ return 'Your browser will show the prompt near the address bar.';
++ }
++
++ function unblockHint(kind) {
++ const ua = navigator.userAgent;
++ const what = KIND[kind]?.label || 'device';
++ if (/Firefox\//.test(ua)) return `Click the padlock in the address bar, then clear the blocked ${what} permission and reload.`;
++ if (/Safari\//.test(ua)) return `Open Safari → Settings for This Website, set ${what} to Allow, then reload.`;
++ return `Click the padlock (or camera icon) in the address bar, set ${what} to Allow, then reload.`;
++ }
++
++ /**
++ * Show the explainer and resolve true if the streamer wants to continue.
++ * Resolves immediately when we already hold a grant — re-explaining a
++ * permission the browser will not even prompt for is pure friction.
++ */
++ function explain(kind, state) {
++ const meta = KIND[kind];
++ return new Promise((resolve) => {
++ const blocked = state === 'denied';
++ const wrap = document.createElement('div');
++ wrap.className = 'bc-perm-overlay';
++ wrap.innerHTML = `
++ <div class="bc-perm-card" role="dialog" aria-modal="true" aria-label="${meta.label} access">
++ <div class="bc-perm-head">
++ <span class="bc-perm-icon ${blocked ? 'is-blocked' : ''}"><i class="${meta.icon}"></i></span>
++ <div>
++ <h3>${blocked ? `Your ${meta.label} is blocked` : `Allow your ${meta.label}?`}</h3>
++ <p>${blocked
++ ? `This site was previously denied ${meta.label} access, so the browser will not ask again on its own.`
++ : `We need it ${meta.why}.`}</p>
++ </div>
++ </div>
++
++ ${blocked ? `
++ <div class="bc-perm-steps">
++ <div class="bc-perm-step"><span>1</span><p>${unblockHint(kind)}</p></div>
++ <div class="bc-perm-step"><span>2</span><p>Come back here and turn the ${meta.label} on again.</p></div>
++ </div>
++ ` : `
++ <div class="bc-perm-mock" aria-hidden="true">
++ <div class="bc-perm-mock-bar">
++ <i class="fa-solid fa-lock"></i><span>openvibe.live</span>
++ </div>
++ <div class="bc-perm-mock-pop">
++ <p><i class="${meta.icon}"></i> Use your ${meta.label}?</p>
++ <div class="bc-perm-mock-btns">
++ <span class="ghost">Block</span><span class="primary">Allow</span>
++ </div>
++ </div>
++ </div>
++ <p class="bc-perm-note"><i class="fa-solid fa-circle-info"></i> ${browserHint()}</p>
++ <p class="bc-perm-note"><i class="fa-solid fa-shield-halved"></i> Nothing is captured or sent until you press <strong>Go Live</strong> — this only lets us list your devices so you can pick one and test it.</p>
++ `}
++
++ <div class="bc-perm-actions">
++ <button type="button" class="btn btn-ghost" data-act="cancel">${blocked ? 'Close' : 'Not now'}</button>
++ ${blocked ? '' : `<button type="button" class="btn btn-primary" data-act="go"><i class="${meta.icon}"></i> Continue</button>`}
++ </div>
++ </div>`;
++ const done = (v) => { try { wrap.remove(); } catch { /* */ } resolve(v); };
++ wrap.addEventListener('click', (e) => {
++ if (e.target === wrap) return done(false);
++ const act = e.target.closest('[data-act]')?.dataset.act;
++ if (act === 'go') done(true);
++ else if (act === 'cancel') done(false);
++ });
++ document.addEventListener('keydown', function esc(e) {
++ if (e.key === 'Escape') { document.removeEventListener('keydown', esc); done(false); }
++ });
++ document.body.appendChild(wrap);
++ });
++ }
++
++ /**
++ * Full flow: explain → prompt → re-enumerate.
++ * Returns { granted, devices, state, error }.
++ *
++ * The re-enumeration after the grant is the part that fixes labels reading
++ * "Default": the list built before a grant carries empty labels AND empty
++ * deviceIds, so it must be thrown away and rebuilt, not patched.
++ */
++ async function request(kind, { silent = false } = {}) {
++ const meta = KIND[kind];
++ if (!meta) return { granted: false, devices: [], state: 'unknown', error: 'unknown kind' };
++ if (!navigator.mediaDevices?.getUserMedia) {
++ return { granted: false, devices: [], state: 'unsupported', error: 'This browser cannot capture media.' };
++ }
++
++ let state = await permissionState(kind);
++ if (state !== 'granted' && !(await hasLabels(meta.deviceKind))) {
++ if (!silent) {
++ const go = await explain(kind, state);
++ if (!go) return { granted: false, devices: [], state, error: 'cancelled' };
++ }
++ }
++
++ let stream = null;
++ try {
++ stream = await navigator.mediaDevices.getUserMedia(meta.constraints);
++ } catch (err) {
++ const denied = err.name === 'NotAllowedError' || err.name === 'SecurityError';
++ if (denied && !silent) await explain(kind, 'denied');
++ return {
++ granted: false, devices: [], state: denied ? 'denied' : 'error',
++ error: denied ? `${meta.label} access was blocked` : (err.message || String(err)),
++ };
++ } finally {
++ // Release immediately — this grant exists to unlock device labels, not to capture.
++ try { stream?.getTracks().forEach(t => t.stop()); } catch { /* */ }
++ }
++
++ const devices = await listDevices(meta.deviceKind);
++ return { granted: true, devices, state: 'granted', error: null };
++ }
++
++ /* ── Live microphone meter ────────────────────────────────── */
++
++ const meters = new Map(); // canvasId -> teardown()
++
++ /**
++ * Run the chosen microphone through an AnalyserNode and paint its level.
++ *
++ * Deliberately opens the SPECIFIC deviceId with an exact constraint, so the
++ * meter proves the device the streamer picked actually produces audio —
++ * a meter fed by the OS default would have happily shown a healthy signal
++ * for the exact bug this feature is meant to catch.
++ */
++ async function startMeter(canvasId, deviceId, statusId) {
++ stopMeter(canvasId);
++ const canvas = document.getElementById(canvasId);
++ if (!canvas) return;
++ const status = statusId ? document.getElementById(statusId) : null;
++ const setStatus = (txt, cls) => {
++ if (!status) return;
++ status.textContent = txt;
++ status.className = `bc-mic-meter-status ${cls || ''}`;
++ };
++
++ let stream = null, ctx = null, raf = null;
++ try {
++ const audio = (deviceId && deviceId !== 'default')
++ ? { deviceId: { exact: deviceId } }
++ : true;
++ stream = await navigator.mediaDevices.getUserMedia({ audio });
++ } catch (err) {
++ setStatus(err.name === 'NotAllowedError' ? 'Microphone blocked' : 'Could not open this microphone', 'is-bad');
++ return;
++ }
++
++ try {
++ ctx = new (window.AudioContext || window.webkitAudioContext)();
++ if (ctx.state === 'suspended') await ctx.resume().catch(() => {});
++ const src = ctx.createMediaStreamSource(stream);
++ const analyser = ctx.createAnalyser();
++ analyser.fftSize = 1024;
++ analyser.smoothingTimeConstant = 0.75;
++ src.connect(analyser);
++ const buf = new Uint8Array(analyser.fftSize);
++
++ const g = canvas.getContext('2d');
++ let peak = 0, silentFrames = 0, everHeard = false;
++
++ const paint = () => {
++ raf = requestAnimationFrame(paint);
++ analyser.getByteTimeDomainData(buf);
++ // RMS around the 128 midpoint, scaled so normal speech sits high
++ // in the bar without clipping the display.
++ let sum = 0;
++ for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; }
++ const rms = Math.sqrt(sum / buf.length);
++ const level = Math.min(1, rms * 3.2);
++ peak = Math.max(level, peak * 0.92);
++
++ const w = canvas.width, h = canvas.height;
++ g.clearRect(0, 0, w, h);
++ g.fillStyle = 'rgba(255,255,255,0.07)';
++ g.fillRect(0, 0, w, h);
++
++ const segs = 28, gap = 2, sw = (w - gap * (segs - 1)) / segs;
++ const lit = Math.round(level * segs);
++ for (let i = 0; i < segs; i++) {
++ const frac = i / segs;
++ g.fillStyle = i < lit
++ ? (frac > 0.86 ? '#ef4444' : frac > 0.66 ? '#f59e0b' : '#22c55e')
++ : 'rgba(255,255,255,0.10)';
++ g.fillRect(i * (sw + gap), 0, sw, h);
++ }
++ const px = Math.round(peak * segs);
++ if (px > 0 && px <= segs) {
++ g.fillStyle = 'rgba(255,255,255,0.75)';
++ g.fillRect(Math.min(segs - 1, px) * (sw + gap), 0, sw, h);
++ }
++
++ if (level > 0.045) { everHeard = true; silentFrames = 0; }
++ else silentFrames++;
++
++ if (everHeard) setStatus(level > 0.9 ? 'Very loud — try lowering your input level' : 'Sounding good', level > 0.9 ? 'is-warn' : 'is-good');
++ else if (silentFrames > 150) setStatus('No sound yet — say something to test', 'is-warn');
++ else setStatus('Listening…', '');
++ };
++ paint();
++ } catch (err) {
++ setStatus('Could not analyse this microphone', 'is-bad');
++ }
++
++ meters.set(canvasId, () => {
++ try { if (raf) cancelAnimationFrame(raf); } catch { /* */ }
++ try { stream?.getTracks().forEach(t => t.stop()); } catch { /* */ }
++ try { ctx?.close(); } catch { /* */ }
++ const c = document.getElementById(canvasId);
++ if (c) { const gg = c.getContext('2d'); gg && gg.clearRect(0, 0, c.width, c.height); }
++ });
++ }
++
++ function stopMeter(canvasId) {
++ const teardown = meters.get(canvasId);
++ if (teardown) { teardown(); meters.delete(canvasId); }
++ }
++
++ function stopAllMeters() { for (const id of [...meters.keys()]) stopMeter(id); }
++
++ /* ── "What will be captured" summary ──────────────────────── */
++
++ /**
++ * Desktop/system audio is the most misunderstood part of screen sharing:
++ * whether it is even offered depends on the browser AND on what the
++ * streamer picks in the share picker (a whole screen usually cannot carry
++ * audio on Linux; a tab usually can). Say so plainly up front rather than
++ * letting them discover it from a silent VOD.
++ */
++ function systemAudioSupport() {
++ const ua = navigator.userAgent;
++ const isFirefox = /Firefox\//.test(ua);
++ const isSafari = /Safari\//.test(ua) && !/Chrome\//.test(ua);
++ const isLinux = /Linux/.test(ua) && !/Android/.test(ua);
++ if (isSafari) return { level: 'no', text: 'Safari cannot share system audio — only your microphone will be captured.' };
++ if (isFirefox) return { level: 'partial', text: 'Firefox only shares audio when you pick a browser tab, not a window or whole screen.' };
++ if (isLinux) return { level: 'partial', text: 'On Linux, system audio usually only works when you share a browser tab.' };
++ return { level: 'yes', text: 'Tick “Share system audio” in the picker to include desktop sound.' };
++ }
++
++ /**
++ * Render the capture summary into `el`.
++ * @param {object} o { mic:boolean, micLabel:string, systemAudio:boolean, camera:boolean, cameraLabel:string }
++ */
++ function renderSummary(el, o) {
++ if (!el) return;
++ const sys = systemAudioSupport();
++ const row = (on, icon, title, detail, warn) => `
++ <div class="bc-cap-row ${on ? 'is-on' : 'is-off'}">
++ <i class="${icon}"></i>
++ <div>
++ <strong>${title}</strong>
++ <span>${detail}</span>
++ </div>
++ <em>${on ? 'On' : 'Off'}</em>
++ </div>${warn ? `<p class="bc-cap-warn"><i class="fa-solid fa-triangle-exclamation"></i> ${warn}</p>` : ''}`;
++
++ el.innerHTML = `
++ <div class="bc-cap-list">
++ ${row(true, 'fa-solid fa-display', 'Screen or window', 'Chosen when you press Go Live', '')}
++ ${row(!!o.mic, 'fa-solid fa-microphone', 'Microphone',
++ o.mic ? (o.micLabel || 'Default microphone') : 'Viewers will not hear your voice', '')}
++ ${row(!!o.systemAudio, 'fa-solid fa-volume-high', 'System audio',
++ o.systemAudio ? 'Desktop and app sound' : 'Desktop sound will not be included',
++ o.systemAudio && sys.level !== 'yes' ? sys.text : '')}
++ ${row(!!o.camera, 'fa-solid fa-video', 'Camera',
++ o.camera ? (o.cameraLabel || 'Default camera') : 'No camera overlay', '')}
++ </div>`;
++ }
++
++ window.bcMediaSetup = {
++ request, permissionState, listDevices, hasLabels,
++ startMeter, stopMeter, stopAllMeters,
++ renderSummary, systemAudioSupport, unblockHint,
++ };
++})();
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 7f841ee8..d335f584 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -483,11 +483,15 @@ function _wsRenderPanel() {
</label>
<div id="bc-ws-screen-mic-device" class="bc-ws-screen-device-inline" style="display:none">
<div style="display:flex;gap:4px;align-items:center">
-- <select id="bc-ws-screen-mic-select" class="form-input form-input-sm" style="max-width:240px;flex:1">
++ <select id="bc-ws-screen-mic-select" class="form-input form-input-sm" style="max-width:240px;flex:1" onchange="_wsOnMicDeviceChange()">
<option value="default">Default Microphone</option>
</select>
<button type="button" class="btn btn-small btn-ghost" title="Refresh device list" onclick="refreshDeviceLists()" style="flex-shrink:0;padding:4px 7px"><i class="fa-solid fa-rotate"></i></button>
</div>
++ <div id="bc-ws-mic-meter-wrap" class="bc-mic-meter-wrap" style="display:none">
++ <canvas id="bc-ws-mic-meter" class="bc-mic-meter" width="360" height="22"></canvas>
++ <span id="bc-ws-mic-meter-status" class="bc-mic-meter-status">Listening…</span>
++ </div>
</div>
<div id="bc-ws-screen-mic-perm" class="bc-ws-screen-perm-inline" style="display:none">
<button class="btn btn-small btn-outline" onclick="_wsRequestMicPermission()">
@@@ -497,7 +501,7 @@ function _wsRenderPanel() {
</div>
<div class="bc-ws-screen-perm-item" style="margin-top:6px">
<label class="bc-toggle-label">
-- <input type="checkbox" id="bc-ws-screen-sysaudio" ${(p.screenSysAudio !== false) ? 'checked' : ''} onchange="_wsMarkDirty()">
++ <input type="checkbox" id="bc-ws-screen-sysaudio" ${(p.screenSysAudio !== false) ? 'checked' : ''} onchange="_wsMarkDirty();_wsUpdateCaptureSummary()">
<i class="fa-solid fa-volume-high"></i> System / Tab Audio
</label>
<p class="muted" style="font-size:0.75rem;margin:2px 0 0 26px">Included in screen capture prompt. Chrome/Edge only.</p>
@@@ -520,7 +524,7 @@ function _wsRenderPanel() {
<div class="form-group" style="margin:0">
<label style="font-size:0.78rem">Camera</label>
<div style="display:flex;gap:4px;align-items:center">
-- <select id="bc-ws-screen-cam-select" class="form-input form-input-sm" style="max-width:240px;flex:1">
++ <select id="bc-ws-screen-cam-select" class="form-input form-input-sm" style="max-width:240px;flex:1" onchange="_wsOnCamDeviceChange()">
<option value="default">Default Camera</option>
</select>
<button type="button" class="btn btn-small btn-ghost" title="Refresh device list" onclick="refreshDeviceLists()" style="flex-shrink:0;padding:4px 7px"><i class="fa-solid fa-rotate"></i></button>
@@@ -551,6 +555,11 @@ function _wsRenderPanel() {
</div>
</div>
++ <div class="bc-ws-screen-section">
++ <label class="bc-ws-screen-section-label">What gets captured</label>
++ <div id="bc-ws-capture-summary"></div>
++ </div>
++
<details class="bc-ws-quality bc-ws-screen-quality">
<summary><i class="fa-solid fa-film"></i> Screen Share Quality</summary>
<div class="bc-ws-quality-inner bc-ws-row">
@@@ -2029,11 +2038,20 @@ function _wsScreenCamToggle(checked) {
if (opts) opts.style.display = checked ? '' : 'none';
_wsMarkDirty();
if (checked) _wsRequestCamPermission();
++ else _wsUpdateCaptureSummary();
}
function _wsScreenMicToggle(checked) {
_wsMarkDirty();
-- if (checked) _wsRequestMicPermission();
++ if (checked) {
++ _wsRequestMicPermission();
++ } else {
++ // Release the preview device as soon as the mic is switched off — holding an
++ // open input just to draw a meter would keep the OS "in use" indicator lit.
++ window.bcMediaSetup?.stopMeter('bc-ws-mic-meter');
++ _wsShowEl('bc-ws-mic-meter-wrap', false);
++ _wsUpdateCaptureSummary();
++ }
}
/** Check if mic/cam permissions are already granted and show inline device selects */
@@@ -2048,6 +2066,8 @@ async function _wsCheckScreenInlinePermissions() {
_wsPopulateInlineDevices('bc-ws-screen-mic-select', mics);
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-perm', false);
++ // Grant already held (labels are populated), so preview straight away.
++ if (document.getElementById('bc-ws-screen-mic')?.checked) _wsStartMicMeter();
} else if (document.getElementById('bc-ws-screen-mic')?.checked) {
_wsShowEl('bc-ws-screen-mic-perm', true);
}
@@@ -2062,43 +2082,83 @@ async function _wsCheckScreenInlinePermissions() {
} catch (err) {
console.warn('[Workspace] Inline permission check failed:', err.message);
}
++ _wsUpdateCaptureSummary();
}
async function _wsRequestMicPermission() {
-- try {
-- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
-- stream.getTracks().forEach(t => t.stop());
-- const devices = await navigator.mediaDevices.enumerateDevices();
-- const mics = devices.filter(d => d.kind === 'audioinput' && d.label);
-- _wsPopulateInlineDevices('bc-ws-screen-mic-select', mics);
-- _wsShowEl('bc-ws-screen-mic-device', true);
-- _wsShowEl('bc-ws-screen-mic-perm', false);
-- // (There is no hidden bc-screen-audio mirror any more: createNewStream() reads
-- // bc-ws-screen-mic-select directly. Writing into a non-existent element was what
-- // hid the id mismatch that discarded the streamer's microphone choice.)
-- } catch (err) {
-- console.warn('[Workspace] Mic permission denied:', err.message);
++ // Explain first, then prompt, then REBUILD the list from a fresh enumerate —
++ // labels and deviceIds are both empty until the grant exists, so a list built
++ // beforehand can only show "Default" and can only ever select the OS default.
++ // (There is no hidden bc-screen-audio mirror any more: createNewStream() reads
++ // bc-ws-screen-mic-select directly.)
++ const r = await window.bcMediaSetup.request('mic');
++ if (!r.granted) {
++ if (r.error && r.error !== 'cancelled') console.warn('[Workspace] Mic permission:', r.error);
_wsShowEl('bc-ws-screen-mic-perm', true);
_wsShowEl('bc-ws-screen-mic-device', false);
++ _wsUpdateCaptureSummary();
++ return;
}
++ _wsPopulateInlineDevices('bc-ws-screen-mic-select', r.devices);
++ _wsShowEl('bc-ws-screen-mic-device', true);
++ _wsShowEl('bc-ws-screen-mic-perm', false);
++ _wsStartMicMeter();
++ _wsUpdateCaptureSummary();
++}
++
++/** Preview the SELECTED mic so "can I be heard" is answerable before going live. */
++function _wsStartMicMeter() {
++ const sel = document.getElementById('bc-ws-screen-mic-select');
++ const on = document.getElementById('bc-ws-screen-mic')?.checked;
++ if (!on || !sel) { window.bcMediaSetup?.stopMeter('bc-ws-mic-meter'); return; }
++ _wsShowEl('bc-ws-mic-meter-wrap', true);
++ window.bcMediaSetup.startMeter('bc-ws-mic-meter', sel.value, 'bc-ws-mic-meter-status');
++}
++
++/** Called when the streamer picks a different microphone. */
++function _wsOnMicDeviceChange() {
++ _wsMarkDirty();
++ _wsStartMicMeter();
++ _wsUpdateCaptureSummary();
++}
++
++function _wsOnCamDeviceChange() {
++ _wsMarkDirty();
++ _wsUpdateCaptureSummary();
++}
++
++/** Keep the "what will be captured" panel honest as toggles change. */
++function _wsUpdateCaptureSummary() {
++ const el = document.getElementById('bc-ws-capture-summary');
++ if (!el || !window.bcMediaSetup) return;
++ const micOn = !!document.getElementById('bc-ws-screen-mic')?.checked;
++ const camOn = !!document.getElementById('bc-ws-screen-cam')?.checked;
++ const micSel = document.getElementById('bc-ws-screen-mic-select');
++ const camSel = document.getElementById('bc-ws-screen-cam-select');
++ const sysEl = document.getElementById('bc-ws-screen-sysaudio'); // checkbox, not a select
++ const labelOf = (sel) => (sel && sel.selectedIndex >= 0 && sel.value !== 'default')
++ ? sel.options[sel.selectedIndex].textContent : '';
++ window.bcMediaSetup.renderSummary(el, {
++ mic: micOn, micLabel: labelOf(micSel),
++ camera: camOn, cameraLabel: labelOf(camSel),
++ systemAudio: sysEl ? !!sysEl.checked : true,
++ });
}
async function _wsRequestCamPermission() {
-- try {
-- const stream = await navigator.mediaDevices.getUserMedia({ video: true });
-- stream.getTracks().forEach(t => t.stop());
-- const devices = await navigator.mediaDevices.enumerateDevices();
-- const cams = devices.filter(d => d.kind === 'videoinput' && d.label);
-- _wsPopulateInlineDevices('bc-ws-screen-cam-select', cams);
-- _wsShowEl('bc-ws-screen-cam-device', true);
-- _wsShowEl('bc-ws-screen-cam-perm', false);
-- // See the note in _wsRequestMicPermission: createNewStream() reads
-- // bc-ws-screen-cam-select directly, so there is nothing to mirror into.
-- } catch (err) {
-- console.warn('[Workspace] Camera permission denied:', err.message);
++ // Same explain -> prompt -> re-enumerate flow as the microphone; see there.
++ const r = await window.bcMediaSetup.request('camera');
++ if (!r.granted) {
++ if (r.error && r.error !== 'cancelled') console.warn('[Workspace] Camera permission:', r.error);
_wsShowEl('bc-ws-screen-cam-perm', true);
_wsShowEl('bc-ws-screen-cam-device', false);
++ _wsUpdateCaptureSummary();
++ return;
}
++ _wsPopulateInlineDevices('bc-ws-screen-cam-select', r.devices);
++ _wsShowEl('bc-ws-screen-cam-device', true);
++ _wsShowEl('bc-ws-screen-cam-perm', false);
++ _wsUpdateCaptureSummary();
}
function _wsPopulateInlineDevices(selectId, devices) {
ddiff --git a/test/broadcast-media-setup.test.js b/test/broadcast-media-setup.test.js
nnew file mode 100644
iindex 00000000..bed5edc2
---- /dev/null
++++ b/test/broadcast-media-setup.test.js
@@@ -0,0 +1,88 @@
++/**
++ * Tranche 1 contract: device permission flow, mic preview, capture summary.
++ *
++ * These are DOM-driven features, so this pins the wiring that silently rots —
++ * the same class of failure as the bc-screen-* id mismatch: a handler that names
++ * an element nobody renders, or a control that renders with no handler.
++ */
++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 setup = read('public/js/broadcast-media-setup.js');
++const workspace = read('public/js/broadcast-workspace.js');
++const devices = read('public/js/broadcast-devices.js');
++const html = read('public/index.html');
++const css = read('public/css/broadcast.css');
++
++// ── A: the module is actually loaded, and before its consumers ───────────────────────
++assert.ok(html.includes('/js/broadcast-media-setup.js'), 'index.html must load broadcast-media-setup.js');
++const at = (f) => html.indexOf(f);
++assert.ok(at('/js/broadcast-media-setup.js') < at('/js/broadcast-workspace.js'),
++ 'media-setup must load before broadcast-workspace.js, which calls into it');
++assert.ok(at('/js/broadcast-media-setup.js') < at('/js/broadcast-devices.js'),
++ 'media-setup must load before broadcast-devices.js, which calls into it');
++console.log('OK A: broadcast-media-setup.js is loaded ahead of both consumers');
++
++// ── B: the public surface consumers rely on exists ───────────────────────────────────
++for (const fn of ['request', 'startMeter', 'stopMeter', 'renderSummary', 'permissionState', 'listDevices']) {
++ assert.ok(new RegExp(`\\b${fn}\\b`).test(setup.slice(setup.indexOf('window.bcMediaSetup'))),
++ `bcMediaSetup must export ${fn}()`);
++}
++console.log('OK B: bcMediaSetup exports the full surface its callers use');
++
++// ── C: every element the JS touches is actually rendered ─────────────────────────────
++const rendered = new Set([...workspace.matchAll(/id="([a-zA-Z0-9_-]+)"/g)].map(m => m[1]));
++for (const id of ['bc-ws-mic-meter', 'bc-ws-mic-meter-status', 'bc-ws-mic-meter-wrap', 'bc-ws-capture-summary']) {
++ assert.ok(rendered.has(id), `workspace must render #${id}`);
++ assert.ok(workspace.includes(`'${id}'`), `workspace JS must reference #${id}`);
++}
++console.log('OK C: meter canvas, status, wrapper and summary container are all rendered and referenced');
++
++// ── D: device changes re-run the preview — a meter pinned to the old device would ────
++// happily show a healthy signal for the exact bug this feature exists to catch.
++assert.ok(/id="bc-ws-screen-mic-select"[^>]*onchange="_wsOnMicDeviceChange\(\)"/.test(workspace),
++ 'changing the microphone must re-run the meter via _wsOnMicDeviceChange()');
++assert.ok(/function _wsOnMicDeviceChange\(\)[\s\S]{0,200}_wsStartMicMeter\(\)/.test(workspace),
++ '_wsOnMicDeviceChange must restart the meter');
++assert.ok(/deviceId: \{ exact: deviceId \}/.test(setup),
++ 'the meter must open the SELECTED device with an exact constraint, not the OS default');
++console.log('OK D: the meter follows the selected device, with an exact deviceId constraint');
++
++// ── E: permission is explained before it is requested ────────────────────────────────
++const requestFn = setup.slice(setup.indexOf('async function request('), setup.indexOf('/* ── Live microphone meter'));
++// Match the CALL that actually prompts, not the `navigator.mediaDevices?.getUserMedia`
++// capability check that legitimately precedes everything.
++const promptAt = requestFn.indexOf('getUserMedia(meta.constraints)');
++assert.ok(promptAt > 0, 'request() must call getUserMedia(meta.constraints)');
++assert.ok(requestFn.indexOf('explain(') < promptAt,
++ 'the explainer must run before getUserMedia() fires the browser prompt');
++assert.ok(/listDevices\(meta\.deviceKind\)/.test(requestFn),
++ 'devices must be re-enumerated AFTER the grant — labels and deviceIds are empty before it');
++assert.ok(/unblockHint/.test(setup), 'a denied permission must come with recovery instructions');
++console.log('OK E: explain -> prompt -> re-enumerate, with a recovery path when denied');
++
++// ── F: the blunt combined prompt is gone ─────────────────────────────────────────────
++const populate = devices.slice(devices.indexOf('async function populateDeviceLists()'),
++ devices.indexOf('function _getPreferredCameraId()'));
++assert.ok(populate.includes("bcMediaSetup.request('mic')") && populate.includes("bcMediaSetup.request('camera')"),
++ 'populateDeviceLists must ask per-device through the explainer');
++const combinedIdx = populate.indexOf('{ audio: true, video: true }');
++assert.ok(combinedIdx === -1 || populate.lastIndexOf('window.bcMediaSetup') < combinedIdx,
++ 'the combined audio+video prompt may only survive as a no-bcMediaSetup fallback');
++console.log('OK F: camera and mic are requested separately, each behind its own explainer');
++
++// ── G: the meter releases the device when the mic is switched off ────────────────────
++assert.ok(/_wsScreenMicToggle[\s\S]{0,400}stopMeter\('bc-ws-mic-meter'\)/.test(workspace),
++ 'turning the mic off must stop the meter, or the OS in-use indicator stays lit');
++console.log('OK G: preview device released when the microphone is switched off');
++
++// ── H: styles exist for everything the JS emits ──────────────────────────────────────
++for (const cls of ['bc-perm-overlay', 'bc-perm-card', 'bc-perm-mock', 'bc-mic-meter', 'bc-cap-row', 'bc-cap-warn']) {
++ assert.ok(css.includes(`.${cls}`), `broadcast.css must style .${cls}`);
++}
++console.log('OK H: permission card, meter and capture summary are all styled');
++
++console.log('✅ broadcast media setup test passed');