openvibers/openvibe.live Commits

Broadcast: read the screen-share mic/camera the streamer actually picked

OP OpenVibers committed 19 Aug 2026 · 8d559f5f48 · 3 files, +118 −9

createNewStream() read four element ids that the screen-share panel does not
render. broadcast-workspace.js builds that panel as bc-ws-screen-mic /
-mic-select / -cam / -cam-select; the older bc-screen-* names survive only as
hidden stubs carrying fixed values:

    <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">
    <input type="checkbox" id="bc-screen-cam-enabled"         style="display:none">
    <select  id="bc-screen-audio"  style="display:none"><option value="default">
    <select  id="bc-screen-camera" style="display:none"><option value="default">

Nothing ever copied the streamer's selection into those stubs — the workspace
mirrored the option LIST into them (_wsPopulateInlineDevices('bc-screen-audio',
mics)) but never the chosen VALUE, and the toggle handlers never touched them
at all. So every screen share captured with audioId 'default':

  - the microphone the streamer picked was discarded and capture fell back to
    the OS default input. Whenever that default was not the intended mic,
    viewers heard nothing, and the UI kept reporting "Default" because that is
    genuinely what was being used.
  - screenCamEnabled read false from a stub that is never checked, so the
    camera overlay never ran in screen-share mode at all.

The `?? true` / `|| 'default'` defaults on each lookup meant a null element
produced a plausible value instead of an error, so nothing surfaced it.

Read the real controls, keeping the legacy ids as a fallback for any host page
that still renders them, and log the resolved devices at capture time. Drop the
two dead mirror writes that made the indirection look intentional.

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

Changes

ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex a2c1c5c4..7f841ee8 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -2073,8 +2073,9 @@ async function _wsRequestMicPermission() {
_wsPopulateInlineDevices('bc-ws-screen-mic-select', mics);
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-perm', false);
-- // Also sync to the hidden form selects used by createNewStream
-- _wsPopulateInlineDevices('bc-screen-audio', mics);
++ // (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);
_wsShowEl('bc-ws-screen-mic-perm', true);
@@@ -2091,8 +2092,8 @@ async function _wsRequestCamPermission() {
_wsPopulateInlineDevices('bc-ws-screen-cam-select', cams);
_wsShowEl('bc-ws-screen-cam-device', true);
_wsShowEl('bc-ws-screen-cam-perm', false);
-- // Also sync to the hidden form selects used by createNewStream
-- _wsPopulateInlineDevices('bc-screen-camera', cams);
++ // 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);
_wsShowEl('bc-ws-screen-cam-perm', true);
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex b9776a3e..5a4b8351 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -1474,11 +1474,31 @@ async function createNewStream() {
_syncCameraSelectionUI(createCamera);
_syncAudioSelectionUI(createAudio);
-- // Read screen share device selections
-- const screenMicEnabled = document.getElementById('bc-screen-mic-enabled')?.checked ?? true;
-- const screenCamEnabled = document.getElementById('bc-screen-cam-enabled')?.checked ?? false;
-- const screenAudio = document.getElementById('bc-screen-audio')?.value || 'default';
-- const screenCamera = document.getElementById('bc-screen-camera')?.value || 'default';
++ // Read screen share device selections.
++ //
++ // These four IDs never existed. The screen-share panel is rendered by
++ // broadcast-workspace.js as bc-ws-screen-mic / -mic-select / -cam / -cam-select, and the
++ // old bc-screen-* names were left behind here when that panel was rewritten. Every lookup
++ // therefore returned null, and the `?? true` / `|| 'default'` defaults quietly papered
++ // over it:
++ // - the chosen microphone was discarded and capture always fell back to the system
++ // default device, which is why viewers heard nothing whenever the OS default was not
++ // the mic the streamer picked, and why the UI kept reporting "Default";
++ // - screenCamEnabled defaulted to FALSE, so the camera overlay never ran at all.
++ // The workspace's own sync helpers wrote into the same dead ids (_wsPopulateInlineDevices
++ // ('bc-screen-audio', ...)), so nothing surfaced the breakage.
++ //
++ // Read the real controls, keeping the legacy ids as a fallback so any other host page
++ // that still renders them keeps working.
++ const _el = (...ids) => ids.map(id => document.getElementById(id)).find(Boolean) || null;
++ const micToggleEl = _el('bc-ws-screen-mic', 'bc-screen-mic-enabled');
++ const camToggleEl = _el('bc-ws-screen-cam', 'bc-screen-cam-enabled');
++ const screenMicEnabled = micToggleEl ? !!micToggleEl.checked : true;
++ const screenCamEnabled = camToggleEl ? !!camToggleEl.checked : false;
++ const screenAudio = _el('bc-ws-screen-mic-select', 'bc-screen-audio')?.value || 'default';
++ const screenCamera = _el('bc-ws-screen-cam-select', 'bc-screen-camera')?.value || 'default';
++ console.log('[Broadcast] Screen-share devices — mic:', screenMicEnabled ? screenAudio : 'off',
++ '| camera:', screenCamEnabled ? screenCamera : 'off');
let _diagStreamId = null; // captured in catch for diagnostic logging
try {
ddiff --git a/test/broadcast-screen-devices.test.js b/test/broadcast-screen-devices.test.js
nnew file mode 100644
iindex 00000000..6fd163b1
---- /dev/null
++++ b/test/broadcast-screen-devices.test.js
@@@ -0,0 +1,88 @@
++/**
++ * Regression: the screen-share capture path must read the controls the UI actually renders.
++ *
++ * broadcast-workspace.js renders the screen-share panel with bc-ws-screen-mic /
++ * -mic-select / -cam / -cam-select. createNewStream() in broadcast.js read a different,
++ * older set of ids (bc-screen-mic-enabled / bc-screen-audio / bc-screen-cam-enabled /
++ * bc-screen-camera) which survive only as hidden stubs carrying hardcoded values:
++ *
++ * <input type="checkbox" id="bc-screen-mic-enabled" checked style="display:none">
++ * <input type="checkbox" id="bc-screen-cam-enabled" style="display:none">
++ * <select id="bc-screen-audio" style="display:none"><option value="default">…
++ * <select id="bc-screen-camera" style="display:none"><option value="default">…
++ *
++ * Nothing ever copied the streamer's SELECTION into those stubs — the workspace mirrored
++ * the option list and not the chosen value — so every screen share captured with
++ * audioId 'default' and cameraId 'default', and screenCamEnabled read as false:
++ *
++ * - the microphone the streamer picked was discarded and capture silently fell back to
++ * the OS default input. When that default was not the intended mic, viewers heard
++ * nothing at all, and the UI kept reporting "Default".
++ * - the camera overlay never ran, because its enable flag came from a stub that is
++ * never checked.
++ *
++ * These assertions pin the contract in both directions: the ids the UI renders, and the
++ * ids the capture path reads.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..');
++const broadcast = fs.readFileSync(path.join(root, 'public/js/broadcast.js'), 'utf8');
++const workspace = fs.readFileSync(path.join(root, 'public/js/broadcast-workspace.js'), 'utf8');
++
++// ── A: the workspace still renders the controls we claim to read ─────────────────────
++for (const id of ['bc-ws-screen-mic', 'bc-ws-screen-mic-select', 'bc-ws-screen-cam', 'bc-ws-screen-cam-select']) {
++ assert.ok(workspace.includes(`id="${id}"`),
++ `broadcast-workspace.js must render #${id} (the screen-share capture path reads it)`);
++}
++console.log('OK A: workspace renders the four screen-share device controls');
++
++// ── B: the capture path reads those ids, not only the hidden stubs ───────────────────
++const capture = broadcast.slice(broadcast.indexOf('async function createNewStream()'),
++ broadcast.indexOf('await startMediaCapture(streamData.id, captureOpts)'));
++assert.ok(capture.length > 0, 'could not locate createNewStream() capture section');
++
++for (const id of ['bc-ws-screen-mic', 'bc-ws-screen-mic-select', 'bc-ws-screen-cam', 'bc-ws-screen-cam-select']) {
++ assert.ok(capture.includes(`'${id}'`),
++ `createNewStream() must read #${id} — reading only the hidden stub discards the streamer's choice`);
++}
++console.log('OK B: createNewStream() reads the real controls');
++
++// ── C: the stubs must never be the ONLY source for these four values ─────────────────
++// If a legacy id appears without its bc-ws- counterpart on the same lookup, the value is
++// coming from a hardcoded hidden input again.
++const legacyPairs = [
++ ['bc-screen-mic-enabled', 'bc-ws-screen-mic'],
++ ['bc-screen-cam-enabled', 'bc-ws-screen-cam'],
++ ['bc-screen-audio', 'bc-ws-screen-mic-select'],
++ ['bc-screen-camera', 'bc-ws-screen-cam-select'],
++];
++for (const [legacy, real] of legacyPairs) {
++ for (const line of capture.split('\n')) {
++ if (line.includes(`'${legacy}'`) && !line.includes('//')) {
++ assert.ok(line.includes(`'${real}'`),
++ `capture path reads #${legacy} without #${real} on the same lookup — the stub is hardcoded, so this silently ignores the streamer's selection`);
++ }
++ }
++}
++console.log('OK C: every legacy-id lookup is paired with the real control as the preferred source');
++
++// ── D: the hidden stubs really are hardcoded (so C is not paranoia) ──────────────────
++assert.ok(/id="bc-screen-mic-enabled"[^>]*checked/.test(workspace),
++ 'bc-screen-mic-enabled stub is hardcoded checked — reading it can never reflect the toggle');
++assert.ok(/id="bc-screen-cam-enabled"(?![^>]*checked)/.test(workspace),
++ 'bc-screen-cam-enabled stub is never checked — reading it always disables the camera overlay');
++console.log('OK D: confirmed the stubs carry fixed values and cannot reflect user intent');
++
++// ── E: the camera overlay flag is derived from the toggle, before capture starts ──────
++assert.ok(/ss\._cameraOverlayEnabled\s*=\s*screenCamEnabled/.test(broadcast),
++ 'the camera overlay flag must come from the screen-share camera toggle');
++const flagAt = broadcast.indexOf('ss._cameraOverlayEnabled = screenCamEnabled');
++const captureAt = broadcast.indexOf('await startMediaCapture(streamData.id, captureOpts)');
++assert.ok(flagAt > 0 && flagAt < captureAt,
++ 'the overlay flag must be set before startMediaCapture() reads it');
++console.log('OK E: camera overlay flag set from the toggle, before capture');
++
++console.log('✅ broadcast screen-share device wiring test passed');