openvibers/openvibe.live Commits
Broadcast: explain permissions before asking, and preview the mic before going live
Changes
@@@ -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; }@@@ -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>@@@ -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));});nnew file mode 100644@@@ -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,++ };++})();@@@ -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) {nnew file mode 100644@@@ -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');
@@ -4041,3 +4041,114 @@
@@ -4041,3 +4041,114 @@
border-radius: 3px;
border-radius: 3px;
font-size: 0.75rem;
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; }
@@ -5030,10 +5030,11 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<script src="/js/broadcast-state.js?v=1" defer></script>
<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-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.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/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/settings.js?v=12" defer></script>
@@ -83,17 +83,26 @@ async function populateDeviceLists() {
@@ -83,17 +83,26 @@ async function populateDeviceLists() {
const preDevices = await navigator.mediaDevices.enumerateDevices();
const preDevices = await navigator.mediaDevices.enumerateDevices();
const alreadyHaveLabels = preDevices.some(d => d.label);
const alreadyHaveLabels = preDevices.some(d => d.label);
if (!alreadyHaveLabels) {
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 {
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();
const devices = await navigator.mediaDevices.enumerateDevices();
@@ -105,7 +114,10 @@ async function populateDeviceLists() {
@@ -105,7 +114,10 @@ async function populateDeviceLists() {
devices.forEach(d => {
devices.forEach(d => {
const opt = document.createElement('option');
const opt = document.createElement('option');
opt.value = d.deviceId;
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 === 'videoinput') camSelect.appendChild(opt);
if (d.kind === 'audioinput') audioSelect.appendChild(opt.cloneNode(true));
if (d.kind === 'audioinput') audioSelect.appendChild(opt.cloneNode(true));
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,367 @@
@@ -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,
+ };
+})();
@@ -483,11 +483,15 @@ function _wsRenderPanel() {
@@ -483,11 +483,15 @@ function _wsRenderPanel() {
</label>
</label>
<div id="bc-ws-screen-mic-device" class="bc-ws-screen-device-inline" style="display:none">
<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">
<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>
<option value="default">Default Microphone</option>
</select>
</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>
<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>
+ <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>
<div id="bc-ws-screen-mic-perm" class="bc-ws-screen-perm-inline" style="display:none">
<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()">
<button class="btn btn-small btn-outline" onclick="_wsRequestMicPermission()">
@@ -497,7 +501,7 @@ function _wsRenderPanel() {
@@ -497,7 +501,7 @@ function _wsRenderPanel() {
</div>
</div>
<div class="bc-ws-screen-perm-item" style="margin-top:6px">
<div class="bc-ws-screen-perm-item" style="margin-top:6px">
<label class="bc-toggle-label">
<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
<i class="fa-solid fa-volume-high"></i> System / Tab Audio
</label>
</label>
<p class="muted" style="font-size:0.75rem;margin:2px 0 0 26px">Included in screen capture prompt. Chrome/Edge only.</p>
<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() {
@@ -520,7 +524,7 @@ function _wsRenderPanel() {
<div class="form-group" style="margin:0">
<div class="form-group" style="margin:0">
<label style="font-size:0.78rem">Camera</label>
<label style="font-size:0.78rem">Camera</label>
<div style="display:flex;gap:4px;align-items:center">
<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>
<option value="default">Default Camera</option>
</select>
</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>
<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() {
@@ -551,6 +555,11 @@ function _wsRenderPanel() {
</div>
</div>
</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">
<details class="bc-ws-quality bc-ws-screen-quality">
<summary><i class="fa-solid fa-film"></i> Screen Share Quality</summary>
<summary><i class="fa-solid fa-film"></i> Screen Share Quality</summary>
<div class="bc-ws-quality-inner bc-ws-row">
<div class="bc-ws-quality-inner bc-ws-row">
@@ -2029,11 +2038,20 @@ function _wsScreenCamToggle(checked) {
@@ -2029,11 +2038,20 @@ function _wsScreenCamToggle(checked) {
if (opts) opts.style.display = checked ? '' : 'none';
if (opts) opts.style.display = checked ? '' : 'none';
_wsMarkDirty();
_wsMarkDirty();
if (checked) _wsRequestCamPermission();
if (checked) _wsRequestCamPermission();
+ else _wsUpdateCaptureSummary();
}
}
function _wsScreenMicToggle(checked) {
function _wsScreenMicToggle(checked) {
_wsMarkDirty();
_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 */
/** Check if mic/cam permissions are already granted and show inline device selects */
@@ -2048,6 +2066,8 @@ async function _wsCheckScreenInlinePermissions() {
@@ -2048,6 +2066,8 @@ async function _wsCheckScreenInlinePermissions() {
_wsPopulateInlineDevices('bc-ws-screen-mic-select', mics);
_wsPopulateInlineDevices('bc-ws-screen-mic-select', mics);
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-perm', false);
_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) {
} else if (document.getElementById('bc-ws-screen-mic')?.checked) {
_wsShowEl('bc-ws-screen-mic-perm', true);
_wsShowEl('bc-ws-screen-mic-perm', true);
}
}
@@ -2062,43 +2082,83 @@ async function _wsCheckScreenInlinePermissions() {
@@ -2062,43 +2082,83 @@ async function _wsCheckScreenInlinePermissions() {
} catch (err) {
} catch (err) {
console.warn('[Workspace] Inline permission check failed:', err.message);
console.warn('[Workspace] Inline permission check failed:', err.message);
}
}
+ _wsUpdateCaptureSummary();
}
}
async function _wsRequestMicPermission() {
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-perm', true);
_wsShowEl('bc-ws-screen-mic-device', false);
_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() {
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-perm', true);
_wsShowEl('bc-ws-screen-cam-device', false);
_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) {
function _wsPopulateInlineDevices(selectId, devices) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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');