openvibers/openvibe.live Commits

Go Live on a phone: camera, lens and microphone as rows of one card; Front | Back as a switch; lenses by name; the mode pickers one compact row (#414)

OP OpenVibers committed 17 days ago · 63d011f2af · 5 files, +408 −240

The owner (2026-10-27): the camera design conflicted with the microphone section, cluttered and confusing.
- One setup card, the same surface and row height as Video quality: Camera (a Front | Back switch on a phone,
  USB too when one is plugged in; elsewhere the camera's name, opening the list), Lens under it when the side has
  more than one, Microphone (its name, opening the system picker). The "On this device" banner and the refresh
  button are gone: "Remembered on this device · Refresh devices" under the card, and plugging a camera or headset
  in or out updates the card by itself.
- Lenses by name: an iPhone's Wide, Ultra wide, Telephoto, Dual wide, Triple; Android's Main, Lens 2.
- Microphones by name: the default first ("Default · Blue Yeti"), each once, no USB ids, Windows' Communications
  duplicate left out.
- Streaming method and capture mode: real buttons in radio groups; on a phone one row of four with names only and
  the chosen one's line under it. Screen Share says when the browser cannot share a screen.
- Go Live centred and thumb-tall; Mic Only's picture is a picker with its preview; Video quality and Screen quality
  rows say what is set and fit a 320 px phone; the page ends below the chat bubble.
- Fixed: back from Screen Share the camera and mic stayed hidden until a reload.

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 0f1f925c..4673745c 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -1803,18 +1803,6 @@
padding-top: 8px;
border-top: 1px solid var(--border);
}
--.bc-ws-device-local-label {
-- display: flex;
-- align-items: center;
-- gap: 6px;
-- font-size: 0.82rem;
-- margin-bottom: 10px;
-- padding: 6px 10px;
-- background: color-mix(in srgb, var(--accent) 6%, transparent);
-- border-radius: 6px;
-- color: var(--text-secondary);
-- flex-wrap: wrap;
--}
.bc-ws-save-status {
font-size: 0.8rem;
color: var(--text-muted);
@@@ -2870,40 +2858,136 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
/* ── The camera chooser before going live (broadcast-devices.js _bcRenderCameraChooser) ──
Phones: Front | Back, and a chip per camera when a side has several. Computers: a card per camera. */
--.bc-cam-chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
--.bc-cam-chooser[hidden], .bc-cam-select-row[hidden] { display: none !important; }
--.bc-cam-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; flex: 1 1 100%; }
--.bc-cam-side, .bc-cam-card, .bc-cam-lens, .bc-cam-refresh {
-- font: inherit; cursor: pointer; color: var(--text-primary); -webkit-tap-highlight-color: transparent;
-- background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); border: 1px solid var(--border);
-- transition: border-color 0.15s, background 0.15s, transform 0.1s;
--}
--.bc-cam-side {
-- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
-- min-height: 74px; border-radius: 16px; font-size: 0.92rem; font-weight: 700;
--}
--.bc-cam-side i { font-size: 1.25rem; color: var(--text-muted); }
--.bc-cam-side.is-on, .bc-cam-card.is-on, .bc-cam-lens.is-on {
-- border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-secondary));
--}
--.bc-cam-side.is-on i, .bc-cam-card.is-on > i:first-child { color: var(--accent); }
--.bc-cam-side:active, .bc-cam-card:active, .bc-cam-lens:active { transform: scale(0.98); }
--.bc-cam-lenses { display: flex; gap: 6px; flex-wrap: wrap; }
--.bc-cam-lens { min-width: 52px; min-height: 36px; padding: 0 12px; border-radius: 999px; font-size: 0.82rem; font-weight: 700; }
--.bc-cam-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; flex: 1 1 0; min-width: 0; }
--.bc-cam-card {
-- display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 0 12px; border-radius: 12px; text-align: left;
-- font-size: 0.88rem; font-weight: 600;
--}
--.bc-cam-card span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.bc-cam-card > i:first-child { width: 18px; text-align: center; color: var(--text-muted); }
--.bc-cam-tick { color: var(--accent); visibility: hidden; }
--.bc-cam-card.is-on .bc-cam-tick { visibility: visible; }
--.bc-cam-refresh { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; color: var(--text-muted); margin-left: auto; }
--.bc-cam-side:focus-visible, .bc-cam-card:focus-visible, .bc-cam-lens:focus-visible, .bc-cam-refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
--@media (prefers-reduced-motion: reduce) { .bc-cam-side, .bc-cam-card, .bc-cam-lens { transition: none; } }
--/* With the chooser the camera takes the row's whole width; the microphone goes under it. */
--.bc-ws-device-row #bc-ws-camera-group.bc-cam-has-chooser { flex: 1 1 100% !important; } /* over its inline flex: 1 */
++/* ── Go Live setup (owner, 2026-10-27: "the camera design conflicts with the microphone section … cluttered … needs
++ total overhaul"). One card with a row each for the camera, its lens and the microphone, the same surface, height
++ and type as Video quality above; a row opens its list (the native picker on a phone), or holds a Front | Back
++ switch. Rows are split by 1px of the card's border colour, so a hidden row (Mic Only, Camera Only) leaves no line. ── */
++.bc-setup-card {
++ display: flex; flex-direction: column; gap: 1px; overflow: hidden;
++ border: 1px solid var(--border); border-radius: 12px; background: var(--border);
++}
++.bc-setup-item { background: var(--bg-card); }
++.bc-setup-row { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; min-height: 52px; padding: 7px 12px; }
++.bc-setup-row[hidden], .bc-cam-sides[hidden], .bc-cam-select-row[hidden] { display: none !important; }
++.bc-setup-label { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; font-size: 0.86rem; font-weight: 650; color: var(--text-primary); }
++.bc-setup-label i { width: 16px; text-align: center; color: var(--accent); }
++.bc-setup-pick { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; font-size: 0.8rem; color: var(--text-muted); }
++.bc-setup-val { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.bc-setup-chev { flex: 0 0 auto; font-size: 0.72rem; }
++/* The select lies over the whole row, unseen: a tap anywhere on it opens the list. 16px keeps iOS from zooming. */
++.bc-setup-native { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; font-size: 16px; border: 0; -webkit-appearance: none; appearance: none; }
++.bc-setup-row:has(> .bc-setup-pick > .bc-setup-native) { transition: background-color 0.15s; }
++.bc-setup-row:has(> .bc-setup-pick > .bc-setup-native):hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
++.bc-setup-row:has(.bc-setup-native:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
++/* The lens row belongs to the camera's: no line between them, its label under the camera's name. */
++.bc-setup-sub { min-height: 44px; padding-top: 0; padding-left: 36px; }
++.bc-setup-sub .bc-setup-label { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
++/* Front | Back and the lenses: a segmented switch, the chosen side filled. */
++.bc-cam-sides, .bc-cam-lenses {
++ flex: 0 0 auto; max-width: 100%; margin-left: auto; display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px;
++ background: color-mix(in srgb, var(--text-primary) 8%, transparent); overflow-x: auto; scrollbar-width: none;
++}
++.bc-cam-sides::-webkit-scrollbar, .bc-cam-lenses::-webkit-scrollbar { display: none; }
++.bc-cam-side, .bc-cam-lens {
++ font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation;
++ display: inline-flex; align-items: center; justify-content: center; gap: 6px; flex: 0 0 auto;
++ min-height: 36px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent;
++ color: var(--text-secondary); font-size: 0.82rem; font-weight: 700; white-space: nowrap;
++ transition: background-color 0.15s, color 0.15s;
++}
++.bc-cam-side i { font-size: 0.82rem; }
++.bc-cam-lens { min-height: 32px; padding: 0 12px; font-size: 0.78rem; }
++/* An iPhone has up to five (Wide, Ultra wide, Telephoto and its two automatic ones): then they take the row's width
++ as an even grid under "Lens", rather than scroll out of sight. */
++.bc-cam-lenses:has(> :nth-child(4)) {
++ flex: 1 1 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); border-radius: 18px; overflow: visible;
++}
++.bc-cam-side:hover, .bc-cam-lens:hover { color: var(--text-primary); }
++.bc-cam-side.is-on, .bc-cam-lens.is-on {
++ background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, var(--on-accent, #fff));
++ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
++}
++.bc-cam-side:focus-visible, .bc-cam-lens:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
++.bc-cam-sides:has(> :nth-child(3)) .bc-cam-side { padding: 0 11px; }
++.bc-cam-sides:has(> :nth-child(3)) .bc-cam-side i { display: none; } /* Front · Back · USB: words only */
++@media (prefers-reduced-motion: reduce) { .bc-cam-side, .bc-cam-lens, .bc-setup-row { transition: none; } }
++.bc-setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; margin: 8px 2px 0; font-size: 0.74rem; color: var(--text-muted); }
++.bc-setup-link { font: inherit; font-weight: 600; color: var(--accent); background: none; border: 0; padding: 6px 0; cursor: pointer; }
++.bc-setup-link:hover { text-decoration: underline; }
++.bc-setup-link i { margin-right: 3px; font-size: 0.9em; }
++
++/* Video quality, the row above the card, matches its rows. */
++.bc-ws-quality { background: var(--bg-card); }
++.bc-ws-quality > summary { min-height: 52px; padding-top: 7px; padding-bottom: 7px; }
++.bc-ws-quality > summary > i:first-child { width: 16px; text-align: center; }
++.bc-ws-quality > summary .bc-sum-title { white-space: nowrap; }
++.bc-ws-quality > summary .bc-sum-val { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
++@container bcws (max-width: 340px) { .bc-sum-more { display: none; } }
++
++/* Before the browser may use them: the same card, a line and its button. */
++.bc-ws-golive-section .bc-perm-request {
++ display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin: 0; padding: 12px; text-align: left;
++ border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card);
++}
++.bc-ws-golive-section .bc-perm-hint { flex: 1 1 200px; display: flex; align-items: flex-start; gap: 8px; margin: 0; font-size: 0.84rem; }
++.bc-ws-golive-section .bc-perm-hint i { color: var(--accent); margin-top: 3px; }
++.bc-ws-golive-section #bc-perm-btn { flex: 1 0 auto; justify-content: center; min-height: 44px; border-radius: 12px; font-weight: 650; }
++.bc-ws-golive-section #bc-perm-debug { flex: 1 1 100%; margin: 0 !important; }
++
++/* Go Live: centred, as tall as a thumb, rounded like the cards. */
++.bc-ws-golive-section .bc-ws-golive-btn {
++ display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%;
++ min-height: 52px; margin-top: 14px; padding: 12px 20px; border-radius: 14px; font-size: 1.02rem; font-weight: 700;
++}
++.bc-ws-golive-section .bc-create-reassurance { margin: 10px 0 0 !important; text-align: center; font-size: 0.78rem !important; color: var(--text-muted); overflow-wrap: anywhere; }
++/* A wide panel keeps the setup and the button a readable width, centred under the two columns. */
++@container bcws (min-width: 960px) {
++ .bc-ws-golive-section.is-browser > * { max-width: 680px; margin-left: auto; margin-right: auto; }
++}
++/* On a phone the chat bubble sits in the bottom corner: the page ends below it, so the address under Go Live shows. */
++@media (max-width: 680px) { .bc-ws-golive-section.is-browser { padding-bottom: 76px; } }
++
++/* How you stream / what you capture: real buttons in a radio group. On a phone they are one row of four with their
++ names only, and the chosen one's line goes under the row (two grids of tall cards took a whole screen). */
++.bc-method-card-sm { font: inherit; color: inherit; width: 100%; -webkit-tap-highlight-color: transparent; }
++.bc-method-card-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.bc-pick-hint { display: none; margin: 6px 2px 0; font-size: 0.76rem; color: var(--text-muted); }
++/* Screen Share where the browser cannot capture a screen (phones): still choosable for the slot, dimmed, and it says so. */
++.bc-method-card-sm.is-unavailable:not(.selected) { opacity: 0.55; }
++@container bcws (max-width: 640px) {
++ .bc-ws-col--method .bc-method-picker-sm { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
++ .bc-ws-col--method .bc-method-card-sm {
++ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
++ min-height: 64px; padding: 10px 4px 9px; border-width: 1px; border-radius: 12px;
++ }
++ .bc-ws-col--method .bc-method-card-sm.selected { box-shadow: 0 0 0 1px var(--accent); }
++ .bc-ws-col--method .bc-method-card-sm i { font-size: 1.05rem; margin: 0; }
++ .bc-ws-col--method .bc-method-card-sm strong { margin: 0; font-size: 0.72rem; line-height: 1.2; }
++ .bc-ws-col--method .bc-method-card-sm .bc-card-sm-hint { display: none; }
++ .bc-ws-col--method .bc-pick-hint { display: block; }
++}
++
++/* Mic Only's picture: a picker with its preview, not the browser's bare file input. */
++.bc-visually-hidden { position: absolute !important; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++.form-group > .bc-file-pick {
++ display: flex; align-items: center; gap: 12px; margin: 0; padding: 10px 12px; cursor: pointer;
++ border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 12px;
++ background: var(--bg-card); transition: background-color 0.15s;
++}
++.form-group > .bc-file-pick:hover { background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); }
++.form-group > .bc-file-pick:has(+ input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
++.bc-file-pick .bc-ws-mic-image-preview {
++ flex: 0 0 52px; width: 52px; height: 52px; max-width: none; margin: 0; overflow: hidden; display: grid; place-items: center;
++ border-radius: 10px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-size: 1.2rem;
++}
++.bc-file-pick .bc-ws-mic-image-preview img { width: 100%; height: 100%; object-fit: cover; border: 0; border-radius: 0; }
++.bc-file-pick-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
++.bc-file-pick-text b { font-size: 0.86rem; font-weight: 650; color: var(--text-primary); }
++.bc-file-pick-text small { font-size: 0.74rem; line-height: 1.35; color: var(--text-muted); }
++.bc-file-pick-btn {
++ flex: 0 0 auto; padding: 6px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; color: var(--accent);
++ border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
++}
/* ── Active Streams: a card per live stream, then the last few with their replays (broadcast.js) ── */
.bc-live-card {
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 65b8532b..d1c932ec 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -30,30 +30,9 @@ async function refreshDeviceLists() {
});
_setSelectValueIfPresent(camSelect, prev);
}
-- if (audioSelect) {
-- const prev = audioSelect.value;
-- audioSelect.innerHTML = '<option value="default">Default</option>';
-- devices.filter(d => d.kind === 'audioinput').forEach(d => {
-- const opt = document.createElement('option');
-- opt.value = d.deviceId;
-- opt.textContent = d.label || `Mic ${d.deviceId.slice(0, 8)}`;
-- audioSelect.appendChild(opt);
-- });
-- _setSelectValueIfPresent(audioSelect, prev);
-- }
++ _bcFillMicSelect(audioSelect, devices);
// Refresh screen share device selects too
-- const screenMicSelect = document.getElementById('bc-ws-screen-mic-select');
-- if (screenMicSelect) {
-- const prev = screenMicSelect.value;
-- screenMicSelect.innerHTML = '<option value="default">Default Microphone</option>';
-- devices.filter(d => d.kind === 'audioinput').forEach(d => {
-- const opt = document.createElement('option');
-- opt.value = d.deviceId;
-- opt.textContent = d.label || `Mic ${d.deviceId.slice(0, 8)}`;
-- screenMicSelect.appendChild(opt);
-- });
-- _setSelectValueIfPresent(screenMicSelect, prev);
-- }
++ _bcFillMicSelect(document.getElementById('bc-ws-screen-mic-select'), devices);
const screenCamSelect = document.getElementById('bc-ws-screen-cam-select');
if (screenCamSelect) {
const prev = screenCamSelect.value;
@@@ -137,6 +116,7 @@ function _setSelectValueIfPresent(select, value) {
const normalized = value || 'default';
const hasExact = Array.from(select.options || []).some(opt => opt.value === normalized);
select.value = hasExact ? normalized : 'default';
++ if (select.id === 'bc-create-audio' || select.id === 'bc-create-camera') _bcSyncPicks();
}
let _switchingCamera = false;
@@@ -147,7 +127,7 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
try {
const sel = document.getElementById('bc-create-camera');
const opt = sel && Array.from(sel.options).find((o) => o.value === normalized);
-- const facing = opt && opt.dataset.facing;
++ const facing = opt && (opt.dataset.facing || 'usb');
if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);
} catch { /* */ }
}
@@@ -224,12 +204,14 @@ function _getCameraFacingHint(device) {
/**
* The cameras as people know them (owner, 2026-10-27: "camera 1, facing front … unformatted camera names is trash").
-- * A phone's cameras are named by side: "Front camera" / "Back camera", and when a side has more than one, "Back camera 2"…
-- * in the phone's own numbering (Android's "camera 0, facing back" is the main one; the list comes in no useful order).
++ * A phone's cameras are named by side: "Front camera" / "Back camera", and when a side has more than one, by lens:
++ * an iPhone names its lenses ("Back Ultra Wide Camera" → lens "Ultra wide", and the plain one is then "Wide"); Android
++ * only numbers them ("camera 0, facing back" is the main one, the list comes in no useful order) → "Main", "Lens 2"…
* A computer's camera keeps its name without the USB ids ("HD Pro Webcam C920 (046d:082d)" → "HD Pro Webcam C920").
* Entries with the same label are one camera (some phones list a logical camera twice).
-- * Returns [{ deviceId, label, name, facing: 'user'|'environment'|null, side: 1-based index on its side, sides }].
++ * Returns [{ deviceId, label, name, lens, facing: 'user'|'environment'|null, side: 1-based index on its side, sides }].
*/
++const _BC_LENSES = [[/ultra\s*wide/i, 'Ultra wide', 1], [/tele(photo)?/i, 'Telephoto', 2], [/dual\s*wide/i, 'Dual wide', 3], [/dual/i, 'Dual', 4], [/triple/i, 'Triple', 5]];
function _bcCameraChoices(devices) {
const seen = new Set();
const list = [];
@@@ -239,10 +221,12 @@ function _bcCameraChoices(devices) {
if (!key || seen.has(key)) continue;
seen.add(key);
const num = /camera2?\s*(\d+)/i.exec(d.label || '');
-- list.push({ deviceId: d.deviceId, label: d.label || '', facing: _getCameraFacingHint(d), order: num ? Number(num[1]) : 1000 + list.length });
++ const lens = _BC_LENSES.find(([re]) => re.test(d.label || ''));
++ list.push({ deviceId: d.deviceId, label: d.label || '', facing: _getCameraFacingHint(d), order: num ? Number(num[1]) : 1000 + list.length,
++ lensName: lens ? lens[1] : null, lensRank: lens ? lens[2] : 0 });
}
const sides = { user: list.filter((c) => c.facing === 'user'), environment: list.filter((c) => c.facing === 'environment') };
-- for (const side of Object.values(sides)) side.sort((a, b) => a.order - b.order);
++ for (const side of Object.values(sides)) side.sort((a, b) => a.lensRank - b.lensRank || a.order - b.order);
let other = 0;
for (const c of list) {
if (c.facing) {
@@@ -250,12 +234,16 @@ function _bcCameraChoices(devices) {
c.side = mine.indexOf(c) + 1;
c.sides = mine.length;
const base = c.facing === 'user' ? 'Front camera' : 'Back camera';
-- c.name = c.side === 1 ? base : `${base} ${c.side}`;
++ const named = mine.some((x) => x.lensName);
++ c.lens = c.lensName || (c.side === 1 ? (named ? 'Wide' : 'Main') : `Lens ${c.side}`);
++ c.name = c.side === 1 ? base : c.lensName ? `${base} (${c.lensName.toLowerCase()})` : `${base} ${c.side}`;
} else {
other += 1;
const clean = c.label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim();
c.name = clean || `Camera ${other}`;
++ c.lens = c.name;
}
++ delete c.lensName; delete c.lensRank;
}
// Front cameras first, then back, then the rest, each side in the phone's order.
const rank = (c) => (c.facing === 'user' ? 0 : c.facing === 'environment' ? 1 : 2);
@@@ -263,98 +251,126 @@ function _bcCameraChoices(devices) {
}
/**
-- * The camera chooser before going live (owner, 2026-10-27: "rather than a drop down … tap a camera button that looks
-- * like the front or back camera … remember whichever you last had selected … default ass shit with unformatted camera
-- * names is trash"). It sits over the go-live camera select (#bc-create-camera), which stays the source of truth.
++ * The microphones by name: the system default first ("Default", or "Default · Blue Yeti" when the browser says which),
++ * then each one once, without USB ids. Chrome lists the default and Windows' "Communications" device as entries of
++ * their own that repeat a real microphone: the default becomes the first choice, Communications is left out.
++ */
++function _bcMicChoices(devices) {
++ const clean = (label) => String(label || '').replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)/gi, '').trim();
++ const mics = (devices || []).filter((d) => d.kind === 'audioinput');
++ const def = mics.find((d) => d.deviceId === 'default');
++ const defOf = def && /^default\s+-\s+(.+)$/i.exec(clean(def.label));
++ const out = [{ deviceId: 'default', name: defOf ? `Default · ${defOf[1]}` : 'Default' }];
++ const seen = new Set();
++ for (const d of mics) {
++ if (d.deviceId === 'default' || d.deviceId === 'communications') continue;
++ const name = clean(d.label) || `Microphone ${out.length}`;
++ if (!d.deviceId || seen.has(name)) continue;
++ seen.add(name);
++ out.push({ deviceId: d.deviceId, name });
++ }
++ return out;
++}
++
++/** Fills a microphone select with _bcMicChoices, keeping its choice. */
++function _bcFillMicSelect(select, devices) {
++ if (!select) return;
++ const prev = select.value;
++ select.replaceChildren(...(_bcMicChoices(devices).map((m) => {
++ const opt = document.createElement('option');
++ opt.value = m.deviceId;
++ opt.textContent = m.name;
++ return opt;
++ })));
++ _setSelectValueIfPresent(select, prev);
++}
++
++/** The setup card's rows show their select's choice ([data-pick-for] → the select's id). */
++function _bcSyncPicks() {
++ document.querySelectorAll('[data-pick-for]').forEach((el) => {
++ const sel = document.getElementById(el.dataset.pickFor);
++ const opt = sel && sel.selectedOptions && sel.selectedOptions[0];
++ el.textContent = opt ? opt.textContent : 'Default';
++ });
++}
++
++/**
++ * The camera before going live (owner, 2026-10-27: "rather than a drop down … tap a camera button that looks like the
++ * front or back camera … remember whichever you last had selected"; then "the camera design conflicts with the
++ * microphone section … total overhaul"). One row of the setup card, like the microphone's, over the go-live camera
++ * select (#bc-create-camera), which stays the source of truth.
*
-- * On a phone: Front | Back, two big buttons, and when the chosen side has more than one camera, a chip per camera on
-- * it (Main, 2, 3). On a computer: a card per camera with its name. Picking one goes through _syncCameraSelectionUI
-- * (remembered as the last camera, and as the last on its side).
++ * On a phone or tablet whose cameras are on two sides (or a USB camera besides): a Front | Back switch in the row, and
++ * when the chosen side has more than one lens, a Lens row under it (Wide · Ultra wide · Telephoto on an iPhone, Main ·
++ * Lens 2 on Android). Elsewhere, or with one side only: the row shows the camera's name and opens the list. Picking one
++ * goes through _syncCameraSelectionUI (remembered as the last camera, and as the last on its side).
*/
function _bcRenderCameraChooser() {
const sel = document.getElementById('bc-create-camera');
if (!sel) return;
const group = document.getElementById('bc-ws-camera-group');
++ const head = sel.closest('.bc-setup-row');
++ const pickRow = sel.closest('.bc-cam-select-row');
let box = document.getElementById('bc-cam-chooser');
++ let lensRow = document.getElementById('bc-cam-lens-row');
const options = Array.from(sel.options).filter((o) => o.value && o.value !== 'default');
-- if (!options.length) { if (box) box.hidden = true; sel.closest('.bc-cam-select-row')?.removeAttribute('hidden'); return; }
++ const choices = options.map((o) => ({ deviceId: o.value, name: o.textContent, lens: o.dataset.lens || o.textContent, facing: o.dataset.facing || null }));
++ const phone = window.matchMedia && window.matchMedia('(max-width: 760px), (pointer: coarse)').matches;
++ const groups = [['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun'], [null, 'USB', 'fa-video']]
++ .map(([facing, label, icon]) => ({ facing, label, icon, list: choices.filter((c) => c.facing === facing) }))
++ .filter((g) => g.list.length);
++ // "Default" is whatever the browser picks: show (and use) a real camera instead, the front one on a phone.
++ if (choices.length && !choices.some((c) => c.deviceId === sel.value)) {
++ const front = choices.find((c) => c.facing === 'user');
++ _syncCameraSelectionUI((phone && front ? front : choices[0]).deviceId, { persist: false });
++ return;
++ }
++ _bcSyncPicks();
++ const switcher = phone && groups.length > 1;
++ if (pickRow) pickRow.hidden = switcher;
++ if (box) box.hidden = !switcher;
++ if (!switcher) { if (lensRow) lensRow.hidden = true; return; }
if (!box) {
box = document.createElement('div');
box.id = 'bc-cam-chooser';
-- box.className = 'bc-cam-chooser';
++ box.className = 'bc-cam-sides';
box.setAttribute('role', 'group');
box.setAttribute('aria-label', 'Camera');
-- (sel.closest('.bc-cam-select-row') || sel).after(box);
-- }
-- box.hidden = false;
-- const row = sel.closest('.bc-cam-select-row');
-- if (row) row.hidden = true;
-- const choices = options.map((o) => ({ deviceId: o.value, name: o.textContent, facing: o.dataset.facing || null }));
-- const phone = window.matchMedia && window.matchMedia('(max-width: 760px), (pointer: coarse)').matches;
-- const sides = { user: choices.filter((c) => c.facing === 'user'), environment: choices.filter((c) => c.facing === 'environment') };
-- // "Default" is whatever the browser picks: show (and use) a real camera instead, the front one on a phone.
-- if (!choices.some((c) => c.deviceId === sel.value)) {
-- _syncCameraSelectionUI((phone && sides.user[0] ? sides.user[0] : choices[0]).deviceId, { persist: false });
-- return;
++ (head || sel.parentElement).append(box);
}
const current = choices.find((c) => c.deviceId === sel.value) || null;
const pick = (id) => { if (id && id !== sel.value) _syncCameraSelectionUI(id); };
-- const btn = (cls, attrs, html, onClick) => {
++ const lastOn = (facing) => { try { return localStorage.getItem(`bc-last-camera-${facing || 'usb'}`); } catch { return null; } };
++ const seg = (cls, on, html, label, onClick) => {
const b = document.createElement('button');
b.type = 'button';
-- b.className = cls;
-- for (const [k, v] of Object.entries(attrs)) b.setAttribute(k, v);
++ b.className = cls + (on ? ' is-on' : '');
++ b.setAttribute('aria-pressed', String(on));
++ if (label) b.setAttribute('aria-label', label);
b.innerHTML = html;
b.addEventListener('click', onClick);
return b;
};
-- const nodes = [];
-- if (phone && (sides.user.length || sides.environment.length)) {
-- const lastOn = (facing) => { try { return localStorage.getItem(`bc-last-camera-${facing}`); } catch { return null; } };
-- const seg = document.createElement('div');
-- seg.className = 'bc-cam-sides';
-- for (const [facing, label, icon] of [['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun']]) {
-- const list = sides[facing];
-- if (!list.length) continue;
-- const on = current && current.facing === facing;
-- seg.append(btn(`bc-cam-side${on ? ' is-on' : ''}`, { 'aria-pressed': String(!!on) },
-- `<i class="fa-solid ${icon}" aria-hidden="true"></i><span>${label}</span>`,
-- () => pick((list.find((c) => c.deviceId === lastOn(facing)) || list[0]).deviceId)));
-- }
-- nodes.push(seg);
-- const side = current && current.facing ? sides[current.facing] : [];
-- if (side.length > 1) {
-- const lenses = document.createElement('div');
-- lenses.className = 'bc-cam-lenses';
-- lenses.setAttribute('aria-label', 'Cameras on this side');
-- side.forEach((c, i) => lenses.append(btn(`bc-cam-lens${c.deviceId === sel.value ? ' is-on' : ''}`,
-- { 'aria-pressed': String(c.deviceId === sel.value), 'aria-label': c.name, title: c.name },
-- i === 0 ? 'Main' : String(i + 1), () => pick(c.deviceId))));
-- nodes.push(lenses);
-- }
-- // A computer camera plugged into a phone or tablet (USB) is still choosable.
-- for (const c of choices.filter((x) => !x.facing)) {
-- nodes.push(btn(`bc-cam-card${c.deviceId === sel.value ? ' is-on' : ''}`, { 'aria-pressed': String(c.deviceId === sel.value) },
-- `<i class="fa-solid fa-video" aria-hidden="true"></i><span></span>`, () => pick(c.deviceId)));
-- nodes[nodes.length - 1].querySelector('span').textContent = c.name;
-- }
-- } else {
-- const cards = document.createElement('div');
-- cards.className = 'bc-cam-cards';
-- for (const c of choices) {
-- const on = c.deviceId === sel.value;
-- const icon = c.facing === 'user' ? 'fa-user' : c.facing === 'environment' ? 'fa-mountain-sun' : 'fa-video';
-- const b = btn(`bc-cam-card${on ? ' is-on' : ''}`, { 'aria-pressed': String(on) },
-- `<i class="fa-solid ${icon}" aria-hidden="true"></i><span></span><i class="fa-solid fa-check bc-cam-tick" aria-hidden="true"></i>`, () => pick(c.deviceId));
-- b.querySelector('span').textContent = c.name;
-- cards.append(b);
-- }
-- nodes.push(cards);
++ box.replaceChildren(...groups.map((g) => seg('bc-cam-side', !!current && current.facing === g.facing,
++ `<i class="fa-solid ${g.icon}" aria-hidden="true"></i><span>${g.label}</span>`, null,
++ () => pick((g.list.find((c) => c.deviceId === lastOn(g.facing)) || g.list[0]).deviceId))));
++ const side = current ? groups.find((g) => g.facing === current.facing) : null;
++ if (!side || side.list.length < 2) { if (lensRow) lensRow.hidden = true; return; }
++ if (!lensRow) {
++ lensRow = document.createElement('div');
++ lensRow.id = 'bc-cam-lens-row';
++ lensRow.className = 'bc-setup-row bc-setup-sub';
++ lensRow.innerHTML = '<span class="bc-setup-label">Lens</span><div class="bc-cam-lenses" role="group" aria-label="Lens"></div>';
++ (head || box).after(lensRow);
}
-- const refresh = btn('bc-cam-refresh', { title: 'Look for cameras again', 'aria-label': 'Look for cameras again' },
-- '<i class="fa-solid fa-rotate" aria-hidden="true"></i>', () => { if (typeof refreshDeviceLists === 'function') refreshDeviceLists(); });
-- box.replaceChildren(...nodes, refresh);
-- if (group) group.classList.toggle('bc-cam-has-chooser', true);
++ lensRow.hidden = false;
++ lensRow.querySelector('.bc-cam-lenses').replaceChildren(...side.list.map((c) => {
++ const b = seg('bc-cam-lens', c.deviceId === sel.value, '', c.name, () => pick(c.deviceId));
++ b.textContent = c.lens;
++ b.title = c.name;
++ return b;
++ }));
++ if (group) group.classList.add('bc-cam-has-chooser');
}
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
@@@ -557,6 +573,7 @@ function _populateCreateDeviceDropdowns(devices) {
const opt = document.createElement('option');
opt.value = c.deviceId;
opt.textContent = c.name;
++ opt.dataset.lens = c.lens;
if (c.facing) opt.dataset.facing = c.facing;
camSelect.appendChild(opt);
}
@@@ -565,13 +582,7 @@ function _populateCreateDeviceDropdowns(devices) {
_bcRenderCameraChooser();
}
if (audioSelect) {
-- audioSelect.innerHTML = '<option value="default">Default</option>';
-- devices.filter(d => d.kind === 'audioinput').forEach(d => {
-- const opt = document.createElement('option');
-- opt.value = d.deviceId;
-- opt.textContent = d.label || `Mic ${d.deviceId.slice(0, 8)}`;
-- audioSelect.appendChild(opt);
-- });
++ _bcFillMicSelect(audioSelect, devices);
// Restore saved microphone selection (unified with broadcastState)
_syncAudioSelectionUI(broadcastState.settings.forceAudio || localStorage.getItem('bc-last-audio') || 'default', { persist: false });
audioSelect.onchange = () => _syncAudioSelectionUI(audioSelect.value);
@@@ -580,13 +591,7 @@ function _populateCreateDeviceDropdowns(devices) {
// Also populate screen share device selects (same device lists)
const screenAudioSelect = document.getElementById('bc-screen-audio');
if (screenAudioSelect) {
-- screenAudioSelect.innerHTML = '<option value="default">Default Microphone</option>';
-- devices.filter(d => d.kind === 'audioinput').forEach(d => {
-- const opt = document.createElement('option');
-- opt.value = d.deviceId;
-- opt.textContent = d.label || `Mic ${d.deviceId.slice(0, 8)}`;
-- screenAudioSelect.appendChild(opt);
-- });
++ _bcFillMicSelect(screenAudioSelect, devices);
// Sync from unified audio state
_syncAudioSelectionUI(broadcastState.settings.forceAudio || 'default', { persist: false });
screenAudioSelect.onchange = () => _syncAudioSelectionUI(screenAudioSelect.value);
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex bef8c834..f31de674 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -352,32 +352,34 @@ function _wsRenderPanel() {
<!-- ═══ Streaming Method ═══ -->
<div class="form-group">
<label>Streaming Method</label>
-- <div class="bc-method-picker bc-method-picker-sm" id="bc-ws-method-picker">
++ <div class="bc-method-picker bc-method-picker-sm" id="bc-ws-method-picker" role="radiogroup" aria-label="Streaming method">
${_wsRenderMethodCards(method)}
</div>
++ <p class="bc-pick-hint" id="bc-ws-method-hint">${esc(_wsPickHint('method', method))}</p>
</div>
<!-- ═══ Browser Sub-mode (only when method is browser) ═══ -->
<div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}">
<div class="form-group">
<label>Browser Capture Mode</label>
-- <div class="bc-method-picker bc-method-picker-sm" id="bc-ws-browser-mode-picker">
++ <div class="bc-method-picker bc-method-picker-sm" id="bc-ws-browser-mode-picker" role="radiogroup" aria-label="Browser capture mode">
${_wsRenderBrowserModeCards(browserMode)}
</div>
++ <p class="bc-pick-hint" id="bc-ws-mode-hint">${esc(_wsPickHint('mode', browserMode))}</p>
</div>
<!-- Microphone Only: image upload -->
<div id="bc-ws-mic-only-opts" style="${browserMode === 'mic_only' ? '' : 'display:none'}">
<div class="form-group">
-- <label><i class="fa-solid fa-image"></i> Placeholder Image</label>
-- <p class="muted" style="font-size:0.82rem;margin-bottom:6px">
-- Displayed to viewers instead of video. Recommended: square, 400\u00d7400+.
-- </p>
-- <input type="file" id="bc-ws-mic-image" accept="image/*" class="form-input form-input-sm"
++ <label class="bc-file-pick" for="bc-ws-mic-image">
++ <span id="bc-ws-mic-image-preview" class="bc-ws-mic-image-preview">
++ ${p.micOnlyImage ? `<img src="${esc(p.micOnlyImage)}" alt="">` : '<i class="fa-solid fa-image" aria-hidden="true"></i>'}
++ </span>
++ <span class="bc-file-pick-text"><b>Picture viewers see</b><small>Shown instead of video. Square, 400\u00d7400 or larger, up to 2 MB.</small></span>
++ <span class="bc-file-pick-btn">${p.micOnlyImage ? 'Change' : 'Choose'}</span>
++ </label>
++ <input type="file" id="bc-ws-mic-image" accept="image/*" class="bc-visually-hidden"
onchange="_wsMicImageChanged(this)">
-- <div id="bc-ws-mic-image-preview" class="bc-ws-mic-image-preview">
-- ${p.micOnlyImage ? `<img src="${esc(p.micOnlyImage)}" alt="Mic-only placeholder">` : ''}
-- </div>
</div>
</div>
@@@ -483,7 +485,7 @@ function _wsRenderPanel() {
</div>
<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><span class="bc-sum-title">Screen quality</span><small class="bc-sum-val">${esc(p.screenResolution || '1080')}p · ${esc(p.screenFps || '30')} fps<span class="bc-sum-more"> · ${esc(p.screenBitrate || 3000)} kbps</span></small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-quality-inner bc-ws-row">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.82rem">Resolution</label>
@@@ -513,7 +515,7 @@ function _wsRenderPanel() {
<!-- Video quality defaults (Class A blob, browser method only) -->
<div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">
<details class="bc-ws-quality">
-- <summary><i class="fa-solid fa-film"></i><span class="bc-sum-title">Video quality</span><small class="bc-sum-val">${esc(p.resolution || '720')}p · ${esc(p.fps || '30')} fps · ${esc(p.bitrate || 2500)} kbps</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
++ <summary><i class="fa-solid fa-film"></i><span class="bc-sum-title">Video quality</span><small class="bc-sum-val">${_wsQualitySummaryHTML(p.resolution || '720', p.fps || '30', p.bitrate || 2500)}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="bc-ws-quality-inner bc-ws-row">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.82rem">Resolution</label>
@@@ -553,22 +555,15 @@ function _wsRenderPanel() {
</div>
<!-- ── Go Live / Auto-detect section ── -->
-- <div class="bc-ws-golive-section" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>
++ <div class="bc-ws-golive-section${method === 'browser' ? ' is-browser' : ''}" id="bc-ws-golive-section"${liveSessionId ? ' style="display:none"' : ''}>
${method === 'browser' ? `
-- <!-- Class B: Device-local preferences (browser only) -->
++ <!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and
++ the microphone; remembered on this device, never saved with the slot. -->
<div id="bc-ws-device-wrap">
-- <div class="bc-ws-device-local-label">
-- <i class="fa-solid fa-mobile-screen-button"></i>
-- <strong>On this device</strong>
-- <span class="muted">&mdash; your camera and mic are remembered here</span>
-- </div>
<div id="bc-perm-request" class="bc-perm-request">
-- <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved"></i>
-- <span id="bc-perm-hint-text">${browserMode === 'mic_only' ? 'Browser needs microphone access to stream.'
-- : browserMode === 'camera_only' ? 'Browser needs camera access to stream.'
-- : browserMode === 'screen' ? 'Browser needs screen capture access to stream.'
-- : 'Browser needs camera &amp; mic access to stream.'}</span></p>
++ <p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
++ <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>
<button id="bc-perm-btn" class="btn btn-outline" type="button"
onclick="requestMediaPermissions()">
<i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
@@@ -582,23 +577,36 @@ function _wsRenderPanel() {
<p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
</div>
<div id="bc-device-selects" style="display:none">
-- <div class="bc-ws-device-row">
-- <div class="form-group" style="margin:0;flex:1" id="bc-ws-camera-group">
-- <label style="font-size:0.82rem"><i class="fa-solid fa-camera"></i> Camera</label>
-- <div class="bc-cam-select-row" style="display:flex;gap:4px;align-items:center">
-- <select id="bc-create-camera" class="form-input form-input-sm" style="flex:1">
-- <option value="default">Default</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 class="bc-setup-card">
++ <div class="bc-setup-item" id="bc-ws-camera-group">
++ <div class="bc-setup-row">
++ <span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>
++ <div class="bc-cam-select-row bc-setup-pick">
++ <span class="bc-setup-val" data-pick-for="bc-create-camera">Default</span>
++ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
++ <select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">
++ <option value="default">Default</option>
++ </select>
++ </div>
</div>
</div>
-- <div class="form-group" style="margin:0;flex:1" id="bc-ws-audio-group">
-- <label style="font-size:0.82rem"><i class="fa-solid fa-microphone"></i> Microphone</label>
-- <select id="bc-create-audio" class="form-input form-input-sm">
-- <option value="default">Default</option>
-- </select>
++ <div class="bc-setup-item" id="bc-ws-audio-group">
++ <div class="bc-setup-row">
++ <span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>
++ <div class="bc-setup-pick">
++ <span class="bc-setup-val" data-pick-for="bc-create-audio">Default</span>
++ <i class="fa-solid fa-chevron-down bc-setup-chev" aria-hidden="true"></i>
++ <select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">
++ <option value="default">Default</option>
++ </select>
++ </div>
++ </div>
</div>
</div>
++ <p class="bc-setup-note">
++ <span>Remembered on this device</span>
++ <button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>
++ </p>
</div>
</div>
@@@ -1021,11 +1029,23 @@ async function _wsRememberQuality(managedStreamId, { resolution, fps, bitrate })
_wsState.profile = profile;
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = String(v); };
set('bc-ws-resolution', profile.resolution); set('bc-ws-fps', profile.fps); set('bc-ws-bitrate', profile.bitrate);
-- const sum = document.querySelector('.bc-ws-quality .bc-sum-val');
-- if (sum) sum.textContent = `${profile.resolution}p · ${profile.fps} fps · ${profile.bitrate} kbps`;
++ _wsSyncQualitySummary();
return true;
}
++/** Video quality's row says what is set: "720p · 30 fps · 2500 kbps" (the bitrate drops off on the narrowest phones). */
++function _wsQualitySummaryHTML(resolution, fps, bitrate) {
++ return `${esc(String(resolution))}p · ${esc(String(fps))} fps<span class="bc-sum-more"> · ${esc(String(bitrate))} kbps</span>`;
++}
++
++function _wsSyncQualitySummary() {
++ const val = (id) => document.getElementById(id)?.value;
++ for (const [sel, pre, bitrate] of [['.bc-ws-quality:not(.bc-ws-screen-quality)', 'bc-ws-', 2500], ['.bc-ws-screen-quality', 'bc-ws-screen-', 3000]]) {
++ const sum = document.querySelector(`${sel} .bc-sum-val`);
++ if (sum && val(`${pre}resolution`)) sum.innerHTML = _wsQualitySummaryHTML(val(`${pre}resolution`), val(`${pre}fps`) || '30', val(`${pre}bitrate`) || bitrate);
++ }
++}
++
/* ── Live stats timer ────────────────────────────────────────── */
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
@@@ -1101,36 +1121,56 @@ function _wsOpenPopoutChat() {
/* ── Method cards ────────────────────────────────────────────── */
--function _wsRenderMethodCards(selected) {
-- const methods = [
-- { id: 'browser', icon: 'globe', label: 'Browser', hint: 'Camera, mic, or screen from your browser' },
-- { id: 'whip', icon: 'satellite-dish', label: 'WHIP', hint: 'OBS WHIP encoder / external WebRTC' },
-- { id: 'rtmp', icon: 'server', label: 'RTMP', hint: 'OBS / Streamlabs / IRL Pro' },
-- { id: 'cli', icon: 'terminal', label: 'CLI / FFmpeg', hint: 'FFmpeg, Pi, RTSP cameras' },
-- ];
-- return methods.map(m => `
-- <div class="bc-method-card-sm${selected === m.id ? ' selected' : ''}"
-- data-wsmethod="${m.id}" onclick="_wsSelectMethod('${m.id}')">
-- <i class="fa-solid fa-${m.icon}"></i>
++const _WS_METHODS = [
++ { id: 'browser', icon: 'globe', label: 'Browser', hint: 'Camera, mic, or screen from your browser' },
++ { id: 'whip', icon: 'satellite-dish', label: 'WHIP', hint: 'OBS WHIP encoder / external WebRTC' },
++ { id: 'rtmp', icon: 'server', label: 'RTMP', hint: 'OBS / Streamlabs / IRL Pro' },
++ { id: 'cli', icon: 'terminal', label: 'CLI / FFmpeg', hint: 'FFmpeg, Pi, RTSP cameras' },
++];
++const _WS_BROWSER_MODES = [
++ { id: 'camera', icon: 'video', label: 'Camera & Mic', hint: 'Webcam + microphone' },
++ { id: 'camera_only', icon: 'camera', label: 'Camera Only', hint: 'Video only, no audio' },
++ { id: 'mic_only', icon: 'microphone', label: 'Mic Only', hint: 'Audio-only with image' },
++ { id: 'screen', icon: 'display', label: 'Screen Share', hint: 'Tab, window, or display' },
++];
++
++// Phones (and some browsers) cannot capture a screen at all.
++function _wsCanShareScreen() {
++ return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
++}
++
++/** What the chosen card is, in a line: under the pickers on a phone, where the cards show only their names. */
++function _wsPickHint(kind, id) {
++ if (kind === 'mode' && id === 'screen' && !_wsCanShareScreen()) return 'This browser cannot share a screen: go live from a computer for that';
++ const m = (kind === 'method' ? _WS_METHODS : _WS_BROWSER_MODES).find((x) => x.id === id);
++ return m ? m.hint : '';
++}
++
++/** The line over Allow …, before the browser may use the camera or microphone. */
++function _wsPermHint(mode) {
++ return mode === 'mic_only' ? 'Let your browser use the microphone to choose one.'
++ : mode === 'camera_only' ? 'Let your browser use the camera to choose one.'
++ : mode === 'screen' ? 'Your browser asks which screen to share when you go live.'
++ : 'Let your browser use the camera and microphone to choose them.';
++}
++
++// The cards are buttons in a radio group: a tap or a key chooses one, and a screen reader says which is chosen.
++function _wsRenderPickCards(list, selected, attr, handler) {
++ return list.map(m => `
++ <button type="button" role="radio" aria-checked="${selected === m.id}" class="bc-method-card-sm${selected === m.id ? ' selected' : ''}${m.id === 'screen' && !_wsCanShareScreen() ? ' is-unavailable' : ''}"
++ ${attr}="${m.id}" onclick="${handler}('${m.id}')">
++ <i class="fa-solid fa-${m.icon}" aria-hidden="true"></i>
<strong>${m.label}</strong>
<span class="bc-card-sm-hint">${m.hint}</span>
-- </div>`).join('');
++ </button>`).join('');
++}
++
++function _wsRenderMethodCards(selected) {
++ return _wsRenderPickCards(_WS_METHODS, selected, 'data-wsmethod', '_wsSelectMethod');
}
function _wsRenderBrowserModeCards(selected) {
-- const modes = [
-- { id: 'camera', icon: 'video', label: 'Camera & Mic', hint: 'Webcam + microphone' },
-- { id: 'camera_only', icon: 'camera', label: 'Camera Only', hint: 'Video only, no audio' },
-- { id: 'mic_only', icon: 'microphone', label: 'Mic Only', hint: 'Audio-only with image' },
-- { id: 'screen', icon: 'display', label: 'Screen Share', hint: 'Tab, window, or display' },
-- ];
-- return modes.map(m => `
-- <div class="bc-method-card-sm${selected === m.id ? ' selected' : ''}"
-- data-wsbrowsermode="${m.id}" onclick="_wsSelectBrowserMode('${m.id}')">
-- <i class="fa-solid fa-${m.icon}"></i>
-- <strong>${m.label}</strong>
-- <span class="bc-card-sm-hint">${m.hint}</span>
-- </div>`).join('');
++ return _wsRenderPickCards(_WS_BROWSER_MODES, selected, 'data-wsbrowsermode', '_wsSelectBrowserMode');
}
/* ── Method-specific endpoint info ───────────────────────────── */
@@@ -1634,6 +1674,7 @@ async function _wsRegenerateKey(managedStreamId) {
function _wsMarkDirty() {
_wsState.dirty = true;
_wsUpdateSaveButton();
++ _wsSyncQualitySummary();
}
function _wsUpdateSaveButton() {
@@@ -1782,9 +1823,12 @@ function _wsSelectBrowserMode(mode) {
if (_wsState.selectedMs) _wsState.selectedMs.browser_mode = mode;
_wsMarkDirty();
-- document.querySelectorAll('[data-wsbrowsermode]').forEach(el =>
-- el.classList.toggle('selected', el.dataset.wsbrowsermode === mode)
-- );
++ document.querySelectorAll('[data-wsbrowsermode]').forEach(el => {
++ el.classList.toggle('selected', el.dataset.wsbrowsermode === mode);
++ el.setAttribute('aria-checked', String(el.dataset.wsbrowsermode === mode));
++ });
++ const modeHint = document.getElementById('bc-ws-mode-hint');
++ if (modeHint) modeHint.textContent = _wsPickHint('mode', mode);
// Show/hide sub-mode options
const micOpts = document.getElementById('bc-ws-mic-only-opts');
@@@ -1807,15 +1851,13 @@ function _wsSelectBrowserMode(mode) {
if (devSelects) devSelects.style.display = 'none';
_wsCheckScreenInlinePermissions();
} else {
++ _wsInitDevicePicker('browser', mode).catch(() => {});
// Update permission button text/icon per mode
const permHintText = document.getElementById('bc-perm-hint-text');
const permBtnText = document.getElementById('bc-perm-btn-text');
const permBtn = document.getElementById('bc-perm-btn');
if (permHintText) {
-- const hints = { mic_only: 'Browser needs microphone access to stream.',
-- camera_only: 'Browser needs camera access to stream.',
-- camera: 'Browser needs camera & mic access to stream.' };
-- permHintText.textContent = hints[mode] || hints.camera;
++ permHintText.textContent = _wsPermHint(mode);
}
if (permBtnText) {
const labels = { mic_only: 'Allow Microphone', camera_only: 'Allow Camera',
@@@ -1862,7 +1904,14 @@ function _wsMicImageChanged(input) {
reader.onload = function (e) {
_wsState.profile.micOnlyImage = e.target.result;
const preview = document.getElementById('bc-ws-mic-image-preview');
-- if (preview) preview.innerHTML = `<img src="${e.target.result}" alt="Mic-only placeholder">`;
++ if (preview) {
++ const img = document.createElement('img');
++ img.src = e.target.result;
++ img.alt = '';
++ preview.replaceChildren(img);
++ const btn = preview.parentElement && preview.parentElement.querySelector('.bc-file-pick-btn');
++ if (btn) btn.textContent = 'Change';
++ }
_wsMarkDirty();
};
reader.readAsDataURL(file);
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex d094483f..c320e5cc 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -4646,6 +4646,10 @@ function _refreshVerticalPreview() {
navigator.mediaDevices.addEventListener('devicechange', async () => {
console.log('[Broadcast] Device change detected — re-enumerating devices');
await populateDeviceLists().catch(() => {});
++ // The Go Live card follows too (a camera or headset plugged in or out), so it needs no refresh button.
++ if (document.getElementById('bc-create-camera')) {
++ await navigator.mediaDevices.enumerateDevices().then(_populateCreateDeviceDropdowns).catch(() => {});
++ }
await _checkActiveDevicePresence();
});
}
ddiff --git a/test/broadcast-cameras.test.js b/test/broadcast-cameras.test.js
iindex 7786b16d..963321ca 100644
---- a/test/broadcast-cameras.test.js
++++ b/test/broadcast-cameras.test.js
@@@ -19,30 +19,56 @@ const fn = (src, name) => {
for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
return src.slice(start, i + 1);
};
++const lensTable = /const _BC_LENSES = .*;/.exec(devices)[0];
// eslint-disable-next-line no-new-func
--const choices = new Function(`${fn(devices, '_getCameraFacingHint')}\n${fn(devices, '_bcCameraChoices')}; return _bcCameraChoices;`)();
++const choices = new Function(`${lensTable}\n${fn(devices, '_getCameraFacingHint')}\n${fn(devices, '_bcCameraChoices')}; return _bcCameraChoices;`)();
++// eslint-disable-next-line no-new-func
++const mics = new Function(`${fn(devices, '_bcMicChoices')}; return _bcMicChoices;`)();
const dev = (deviceId, label) => ({ kind: 'videoinput', deviceId, label });
// Android lists its cameras in no useful order; "camera 0, facing back" is the main one. A camera listed twice is one.
const phone = choices([dev('a', 'camera 1, facing front'), dev('b', 'camera 3, facing front'), dev('c', 'camera 2, facing back'), dev('d', 'camera 0, facing back'), dev('e', 'camera 0, facing back')]);
assert.deepStrictEqual(phone.map((c) => [c.deviceId, c.name]), [['a', 'Front camera'], ['b', 'Front camera 2'], ['d', 'Back camera'], ['c', 'Back camera 2']]);
assert.deepStrictEqual(phone.map((c) => c.facing), ['user', 'user', 'environment', 'environment']);
--// iOS and older Android names.
--assert.deepStrictEqual(choices([dev('f', 'Front Camera'), dev('r', 'Back Camera'), dev('u', 'Back Ultra Wide Camera')]).map((c) => c.name), ['Front camera', 'Back camera', 'Back camera 2']);
++// Android only numbers its lenses: the main one, then Lens 2…
++assert.deepStrictEqual(phone.map((c) => c.lens), ['Main', 'Lens 2', 'Main', 'Lens 2']);
++// An iPhone names them (owner, 2026-10-27: the chips said "Main" and "2"): the plain camera is the Wide one, then by lens.
++const iphone = choices([dev('t', 'Back Triple Camera'), dev('f', 'Front Camera'), dev('u', 'Back Ultra Wide Camera'), dev('r', 'Back Camera'), dev('p', 'Back Telephoto Camera'), dev('w', 'Back Dual Wide Camera')]);
++assert.deepStrictEqual(iphone.map((c) => [c.name, c.lens]), [['Front camera', 'Main'], ['Back camera', 'Wide'], ['Back camera (ultra wide)', 'Ultra wide'],
++ ['Back camera (telephoto)', 'Telephoto'], ['Back camera (dual wide)', 'Dual wide'], ['Back camera (triple)', 'Triple']]);
// A computer's cameras keep their names, without the USB ids.
assert.deepStrictEqual(choices([dev('x', 'HD Pro Webcam C920 (046d:082d)'), dev('y', 'Integrated Camera (04f2:b6dd)'), dev('z', 'OBS Virtual Camera')]).map((c) => c.name),
['HD Pro Webcam C920', 'Integrated Camera', 'OBS Virtual Camera']);
// No labels (no permission yet): nothing to name.
assert.deepStrictEqual(choices([dev('', ''), dev('', '')]), []);
--// The chooser: over the select (still the source of truth), Front | Back with lens chips on a phone, cards elsewhere,
--// remembered per side; "Default" is replaced by a real camera.
++// Microphones: the system default first, named when the browser says which; each once; no USB ids; Communications out.
++assert.deepStrictEqual(mics([
++ { kind: 'audioinput', deviceId: 'default', label: 'Default - Blue Yeti Stereo Microphone (b58e:9e84)' },
++ { kind: 'audioinput', deviceId: 'communications', label: 'Communications - Blue Yeti Stereo Microphone (b58e:9e84)' },
++ { kind: 'audioinput', deviceId: 'm1', label: 'Blue Yeti Stereo Microphone (b58e:9e84)' },
++ { kind: 'audioinput', deviceId: 'm2', label: 'Integrated Microphone Array' },
++ { kind: 'audioinput', deviceId: 'm3', label: 'Integrated Microphone Array' },
++ { kind: 'videoinput', deviceId: 'x', label: 'HD Pro Webcam C920' },
++]).map((m) => [m.deviceId, m.name]), [['default', 'Default · Blue Yeti Stereo Microphone'], ['m1', 'Blue Yeti Stereo Microphone'], ['m2', 'Integrated Microphone Array']]);
++assert.deepStrictEqual(mics([{ kind: 'audioinput', deviceId: 'i1', label: 'iPhone Microphone' }]).map((m) => m.name), ['Default', 'iPhone Microphone'], 'Default is always a choice');
++
++// The setup card (owner, 2026-10-27: "the camera design conflicts with the microphone section … total overhaul"): one
++// card, a row each for the camera, its lens and the microphone; the old "On this device" banner and refresh button gone.
++const ws = read('public/js/broadcast-workspace.js');
++assert.ok(ws.includes('<div class="bc-setup-card">') && !ws.includes('bc-ws-device-local-label'));
++assert.ok(ws.includes('data-pick-for="bc-create-camera"') && ws.includes('data-pick-for="bc-create-audio"'), 'each row shows its choice');
++assert.ok(ws.includes('<select id="bc-create-camera" class="bc-setup-native"') && ws.includes('<select id="bc-create-audio" class="bc-setup-native"'), 'the selects stay the source of truth');
++assert.ok(ws.includes("_wsInitDevicePicker('browser', mode).catch(() => {});"), 'back from Screen Share, the camera and mic come back');
++// The chooser: Front | Back (and USB) in the camera's row on a phone, a Lens row when the side has more than one,
++// remembered per side; elsewhere the row opens the list. "Default" is replaced by a real camera.
const render = fn(devices, '_bcRenderCameraChooser');
--assert.ok(render.includes("['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun']"));
--assert.ok(render.includes("i === 0 ? 'Main' : String(i + 1)"), 'a chip per camera on the chosen side');
--assert.ok(render.includes('_syncCameraSelectionUI((phone && sides.user[0] ? sides.user[0] : choices[0]).deviceId, { persist: false })'), 'no "Default"');
++assert.ok(render.includes("[['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun'], [null, 'USB', 'fa-video']]"));
++assert.ok(render.includes('const switcher = phone && groups.length > 1;'), 'a switch only with two sides to switch between');
++assert.ok(render.includes('b.textContent = c.lens;'), 'lens chips by lens name');
++assert.ok(render.includes('_syncCameraSelectionUI((phone && front ? front : choices[0]).deviceId, { persist: false })'), 'no "Default"');
assert.ok(/localStorage\.setItem\(`bc-last-camera-\$\{facing\}`, normalized\)/.test(fn(devices, '_syncCameraSelectionUI')), 'the last camera on each side is remembered');
--assert.ok(read('public/js/broadcast-workspace.js').includes('<div class="bc-cam-select-row"'), 'the select row the chooser replaces');
++assert.ok(read('public/js/broadcast.js').includes('.then(_populateCreateDeviceDropdowns)'), 'a camera or headset plugged in shows up without a refresh');
// The Go Live page's phone bar: nothing before the streams are known; "Resume your stream" while one of theirs is live.
const dock = fn(read('public/js/broadcast-studio.js'), 'syncGoLiveDock');