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)
Changes
@@@ -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 {@@@ -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);@@@ -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">— 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 & 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 optionsconst 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 modeconst 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);@@@ -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();});}@@@ -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');
@@ -1803,18 +1803,6 @@
@@ -1803,18 +1803,6 @@
padding-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
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 {
.bc-ws-save-status {
font-size: 0.8rem;
font-size: 0.8rem;
color: var(--text-muted);
color: var(--text-muted);
@@ -2870,40 +2858,136 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
@@ -2870,40 +2858,136 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
/* ── The camera chooser before going live (broadcast-devices.js _bcRenderCameraChooser) ──
/* ── 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. */
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) ── */
/* ── Active Streams: a card per live stream, then the last few with their replays (broadcast.js) ── */
.bc-live-card {
.bc-live-card {
@@ -30,30 +30,9 @@ async function refreshDeviceLists() {
@@ -30,30 +30,9 @@ async function refreshDeviceLists() {
});
});
_setSelectValueIfPresent(camSelect, prev);
_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
// 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');
const screenCamSelect = document.getElementById('bc-ws-screen-cam-select');
if (screenCamSelect) {
if (screenCamSelect) {
const prev = screenCamSelect.value;
const prev = screenCamSelect.value;
@@ -137,6 +116,7 @@ function _setSelectValueIfPresent(select, value) {
@@ -137,6 +116,7 @@ function _setSelectValueIfPresent(select, value) {
const normalized = value || 'default';
const normalized = value || 'default';
const hasExact = Array.from(select.options || []).some(opt => opt.value === normalized);
const hasExact = Array.from(select.options || []).some(opt => opt.value === normalized);
select.value = hasExact ? normalized : 'default';
select.value = hasExact ? normalized : 'default';
+ if (select.id === 'bc-create-audio' || select.id === 'bc-create-camera') _bcSyncPicks();
}
}
let _switchingCamera = false;
let _switchingCamera = false;
@@ -147,7 +127,7 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
@@ -147,7 +127,7 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
try {
try {
const sel = document.getElementById('bc-create-camera');
const sel = document.getElementById('bc-create-camera');
const opt = sel && Array.from(sel.options).find((o) => o.value === normalized);
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);
if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);
} catch { /* */ }
} catch { /* */ }
}
}
@@ -224,12 +204,14 @@ function _getCameraFacingHint(device) {
@@ -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").
* 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").
* 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).
* 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) {
function _bcCameraChoices(devices) {
const seen = new Set();
const seen = new Set();
const list = [];
const list = [];
@@ -239,10 +221,12 @@ function _bcCameraChoices(devices) {
@@ -239,10 +221,12 @@ function _bcCameraChoices(devices) {
if (!key || seen.has(key)) continue;
if (!key || seen.has(key)) continue;
seen.add(key);
seen.add(key);
const num = /camera2?\s*(\d+)/i.exec(d.label || '');
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') };
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;
let other = 0;
for (const c of list) {
for (const c of list) {
if (c.facing) {
if (c.facing) {
@@ -250,12 +234,16 @@ function _bcCameraChoices(devices) {
@@ -250,12 +234,16 @@ function _bcCameraChoices(devices) {
c.side = mine.indexOf(c) + 1;
c.side = mine.indexOf(c) + 1;
c.sides = mine.length;
c.sides = mine.length;
const base = c.facing === 'user' ? 'Front camera' : 'Back camera';
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 {
} else {
other += 1;
other += 1;
const clean = c.label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim();
const clean = c.label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim();
c.name = clean || `Camera ${other}`;
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.
// 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);
const rank = (c) => (c.facing === 'user' ? 0 : c.facing === 'environment' ? 1 : 2);
@@ -263,98 +251,126 @@ function _bcCameraChoices(devices) {
@@ -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() {
function _bcRenderCameraChooser() {
const sel = document.getElementById('bc-create-camera');
const sel = document.getElementById('bc-create-camera');
if (!sel) return;
if (!sel) return;
const group = document.getElementById('bc-ws-camera-group');
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 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');
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) {
if (!box) {
box = document.createElement('div');
box = document.createElement('div');
box.id = 'bc-cam-chooser';
box.id = 'bc-cam-chooser';
- box.className = 'bc-cam-chooser';
+ box.className = 'bc-cam-sides';
box.setAttribute('role', 'group');
box.setAttribute('role', 'group');
box.setAttribute('aria-label', 'Camera');
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 current = choices.find((c) => c.deviceId === sel.value) || null;
const pick = (id) => { if (id && id !== sel.value) _syncCameraSelectionUI(id); };
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');
const b = document.createElement('button');
b.type = '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.innerHTML = html;
b.addEventListener('click', onClick);
b.addEventListener('click', onClick);
return b;
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 } = {}) {
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
@@ -557,6 +573,7 @@ function _populateCreateDeviceDropdowns(devices) {
@@ -557,6 +573,7 @@ function _populateCreateDeviceDropdowns(devices) {
const opt = document.createElement('option');
const opt = document.createElement('option');
opt.value = c.deviceId;
opt.value = c.deviceId;
opt.textContent = c.name;
opt.textContent = c.name;
+ opt.dataset.lens = c.lens;
if (c.facing) opt.dataset.facing = c.facing;
if (c.facing) opt.dataset.facing = c.facing;
camSelect.appendChild(opt);
camSelect.appendChild(opt);
}
}
@@ -565,13 +582,7 @@ function _populateCreateDeviceDropdowns(devices) {
@@ -565,13 +582,7 @@ function _populateCreateDeviceDropdowns(devices) {
_bcRenderCameraChooser();
_bcRenderCameraChooser();
}
}
if (audioSelect) {
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)
// Restore saved microphone selection (unified with broadcastState)
_syncAudioSelectionUI(broadcastState.settings.forceAudio || localStorage.getItem('bc-last-audio') || 'default', { persist: false });
_syncAudioSelectionUI(broadcastState.settings.forceAudio || localStorage.getItem('bc-last-audio') || 'default', { persist: false });
audioSelect.onchange = () => _syncAudioSelectionUI(audioSelect.value);
audioSelect.onchange = () => _syncAudioSelectionUI(audioSelect.value);
@@ -580,13 +591,7 @@ function _populateCreateDeviceDropdowns(devices) {
@@ -580,13 +591,7 @@ function _populateCreateDeviceDropdowns(devices) {
// Also populate screen share device selects (same device lists)
// Also populate screen share device selects (same device lists)
const screenAudioSelect = document.getElementById('bc-screen-audio');
const screenAudioSelect = document.getElementById('bc-screen-audio');
if (screenAudioSelect) {
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
// Sync from unified audio state
_syncAudioSelectionUI(broadcastState.settings.forceAudio || 'default', { persist: false });
_syncAudioSelectionUI(broadcastState.settings.forceAudio || 'default', { persist: false });
screenAudioSelect.onchange = () => _syncAudioSelectionUI(screenAudioSelect.value);
screenAudioSelect.onchange = () => _syncAudioSelectionUI(screenAudioSelect.value);
@@ -352,32 +352,34 @@ function _wsRenderPanel() {
@@ -352,32 +352,34 @@ function _wsRenderPanel() {
<!-- ═══ Streaming Method ═══ -->
<!-- ═══ Streaming Method ═══ -->
<div class="form-group">
<div class="form-group">
<label>Streaming Method</label>
<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)}
${_wsRenderMethodCards(method)}
</div>
</div>
+ <p class="bc-pick-hint" id="bc-ws-method-hint">${esc(_wsPickHint('method', method))}</p>
</div>
</div>
<!-- ═══ Browser Sub-mode (only when method is browser) ═══ -->
<!-- ═══ Browser Sub-mode (only when method is browser) ═══ -->
<div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}">
<div id="bc-ws-browser-submodes" style="${method === 'browser' ? '' : 'display:none'}">
<div class="form-group">
<div class="form-group">
<label>Browser Capture Mode</label>
<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)}
${_wsRenderBrowserModeCards(browserMode)}
</div>
</div>
+ <p class="bc-pick-hint" id="bc-ws-mode-hint">${esc(_wsPickHint('mode', browserMode))}</p>
</div>
</div>
<!-- Microphone Only: image upload -->
<!-- Microphone Only: image upload -->
<div id="bc-ws-mic-only-opts" style="${browserMode === 'mic_only' ? '' : 'display:none'}">
<div id="bc-ws-mic-only-opts" style="${browserMode === 'mic_only' ? '' : 'display:none'}">
<div class="form-group">
<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)">
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>
</div>
</div>
@@ -483,7 +485,7 @@ function _wsRenderPanel() {
@@ -483,7 +485,7 @@ function _wsRenderPanel() {
</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><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="bc-ws-quality-inner bc-ws-row">
<div class="form-group" style="margin:0;flex:1">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.82rem">Resolution</label>
<label style="font-size:0.82rem">Resolution</label>
@@ -513,7 +515,7 @@ function _wsRenderPanel() {
@@ -513,7 +515,7 @@ function _wsRenderPanel() {
<!-- Video quality defaults (Class A blob, browser method only) -->
<!-- Video quality defaults (Class A blob, browser method only) -->
<div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">
<div id="bc-ws-quality-wrap" style="${method === 'browser' ? '' : 'display:none'}">
<details class="bc-ws-quality">
<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="bc-ws-quality-inner bc-ws-row">
<div class="form-group" style="margin:0;flex:1">
<div class="form-group" style="margin:0;flex:1">
<label style="font-size:0.82rem">Resolution</label>
<label style="font-size:0.82rem">Resolution</label>
@@ -553,22 +555,15 @@ function _wsRenderPanel() {
@@ -553,22 +555,15 @@ function _wsRenderPanel() {
</div>
</div>
<!-- ── Go Live / Auto-detect section ── -->
<!-- ── 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' ? `
${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 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">— your camera and mic are remembered here</span>
- </div>
<div id="bc-perm-request" class="bc-perm-request">
<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 & 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"
<button id="bc-perm-btn" class="btn btn-outline" type="button"
onclick="requestMediaPermissions()">
onclick="requestMediaPermissions()">
<i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
<i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
@@ -582,23 +577,36 @@ function _wsRenderPanel() {
@@ -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>
<p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
</div>
</div>
<div id="bc-device-selects" style="display:none">
<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>
</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>
</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>
</div>
</div>
@@ -1021,11 +1029,23 @@ async function _wsRememberQuality(managedStreamId, { resolution, fps, bitrate })
@@ -1021,11 +1029,23 @@ async function _wsRememberQuality(managedStreamId, { resolution, fps, bitrate })
_wsState.profile = profile;
_wsState.profile = profile;
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = String(v); };
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);
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;
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 ────────────────────────────────────────── */
/* ── Live stats timer ────────────────────────────────────────── */
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
function _wsStartLiveStatsTimer(liveSessionId, liveSessionData) {
@@ -1101,36 +1121,56 @@ function _wsOpenPopoutChat() {
@@ -1101,36 +1121,56 @@ function _wsOpenPopoutChat() {
/* ── Method cards ────────────────────────────────────────────── */
/* ── 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>
<strong>${m.label}</strong>
<span class="bc-card-sm-hint">${m.hint}</span>
<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) {
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 ───────────────────────────── */
/* ── Method-specific endpoint info ───────────────────────────── */
@@ -1634,6 +1674,7 @@ async function _wsRegenerateKey(managedStreamId) {
@@ -1634,6 +1674,7 @@ async function _wsRegenerateKey(managedStreamId) {
function _wsMarkDirty() {
function _wsMarkDirty() {
_wsState.dirty = true;
_wsState.dirty = true;
_wsUpdateSaveButton();
_wsUpdateSaveButton();
+ _wsSyncQualitySummary();
}
}
function _wsUpdateSaveButton() {
function _wsUpdateSaveButton() {
@@ -1782,9 +1823,12 @@ function _wsSelectBrowserMode(mode) {
@@ -1782,9 +1823,12 @@ function _wsSelectBrowserMode(mode) {
if (_wsState.selectedMs) _wsState.selectedMs.browser_mode = mode;
if (_wsState.selectedMs) _wsState.selectedMs.browser_mode = mode;
_wsMarkDirty();
_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
// Show/hide sub-mode options
const micOpts = document.getElementById('bc-ws-mic-only-opts');
const micOpts = document.getElementById('bc-ws-mic-only-opts');
@@ -1807,15 +1851,13 @@ function _wsSelectBrowserMode(mode) {
@@ -1807,15 +1851,13 @@ function _wsSelectBrowserMode(mode) {
if (devSelects) devSelects.style.display = 'none';
if (devSelects) devSelects.style.display = 'none';
_wsCheckScreenInlinePermissions();
_wsCheckScreenInlinePermissions();
} else {
} else {
+ _wsInitDevicePicker('browser', mode).catch(() => {});
// Update permission button text/icon per mode
// Update permission button text/icon per mode
const permHintText = document.getElementById('bc-perm-hint-text');
const permHintText = document.getElementById('bc-perm-hint-text');
const permBtnText = document.getElementById('bc-perm-btn-text');
const permBtnText = document.getElementById('bc-perm-btn-text');
const permBtn = document.getElementById('bc-perm-btn');
const permBtn = document.getElementById('bc-perm-btn');
if (permHintText) {
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) {
if (permBtnText) {
const labels = { mic_only: 'Allow Microphone', camera_only: 'Allow Camera',
const labels = { mic_only: 'Allow Microphone', camera_only: 'Allow Camera',
@@ -1862,7 +1904,14 @@ function _wsMicImageChanged(input) {
@@ -1862,7 +1904,14 @@ function _wsMicImageChanged(input) {
reader.onload = function (e) {
reader.onload = function (e) {
_wsState.profile.micOnlyImage = e.target.result;
_wsState.profile.micOnlyImage = e.target.result;
const preview = document.getElementById('bc-ws-mic-image-preview');
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();
_wsMarkDirty();
};
};
reader.readAsDataURL(file);
reader.readAsDataURL(file);
@@ -4646,6 +4646,10 @@ function _refreshVerticalPreview() {
@@ -4646,6 +4646,10 @@ function _refreshVerticalPreview() {
navigator.mediaDevices.addEventListener('devicechange', async () => {
navigator.mediaDevices.addEventListener('devicechange', async () => {
console.log('[Broadcast] Device change detected — re-enumerating devices');
console.log('[Broadcast] Device change detected — re-enumerating devices');
await populateDeviceLists().catch(() => {});
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();
await _checkActiveDevicePresence();
});
});
}
}
@@ -19,30 +19,56 @@ const fn = (src, name) => {
@@ -19,30 +19,56 @@ const fn = (src, name) => {
for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
return src.slice(start, i + 1);
return src.slice(start, i + 1);
};
};
+const lensTable = /const _BC_LENSES = .*;/.exec(devices)[0];
// eslint-disable-next-line no-new-func
// 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 });
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.
// 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')]);
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.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']);
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.
// 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),
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']);
['HD Pro Webcam C920', 'Integrated Camera', 'OBS Virtual Camera']);
// No labels (no permission yet): nothing to name.
// No labels (no permission yet): nothing to name.
assert.deepStrictEqual(choices([dev('', ''), dev('', '')]), []);
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');
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(/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.
// 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');
const dock = fn(read('public/js/broadcast-studio.js'), 'syncGoLiveDock');