openvibers/openvibe.live Commits

Go live: plain help to turn the camera on, three steps, and a camera that never waits on another site (#844)

OP OpenVibers committed 1 days ago · afe54cffde · 6 files, +278 −58

A streamer on a phone saw "Could not access camera/mic: Could not load camera
controls", "Error: Could not load camera controls" and "Picture from Default",
and said "I don't understand … my brain too tired to figure it out" (the owner,
11-12). #843 let the camera kit load again; this makes the card say what to do.

- The camera never waits on openvibe.cam: when cam-kit.js cannot load (blocked,
  slow past five seconds, no core), the same capture core runs from this page.
- The card says what is about to happen ("Your browser will ask first: tap
  Allow. Nobody sees or hears you until you press Go Live.") and the three
  steps: turn on your camera, check how you look and sound, press Go Live.
- A failure fills one box on the card with what happened and what to tap, for
  the browser the person is in: refused (Android Chrome, iPhone Safari, other
  iPhone browsers, Firefox, desktop Chrome/Edge, Safari), another app using the
  camera, no camera (the screen as the way out), an in-app browser (open it in
  Safari or Chrome), a timeout, anything else. The button under it says Try
  again. No raw error text and no error toast.
- Go Live, while the camera is off, says "Turns on your camera first" and does
  it, instead of a "Turn on camera before going live" toast. The phone dock's
  line says "Step 1 of 3 · your browser asks, tap Allow".
- "Picture from Default" is "Picture from your camera" until a device is chosen
  by name; the device lists' first choice is "Automatic".

Tests: test/golive-camera-help.test.js (the help under five browsers, never a
raw error, the fallback core, Go Live doing step 1), test/golive-preflight.test.js.

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex aacf7118..6dfa366f 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2999,6 +2999,19 @@ html[data-ov-motion=off] .bc-clip-name-timer{display: none}
.gl-allow .bc-perm-hint { justify-content: center; flex: none; }
.gl-allow .bc-perm-hint i { display: none; }
.gl-allow-other { border: 0; background: none; color: var(--text-secondary); text-decoration: underline; font: inherit; min-height: 44px; cursor: pointer; }
++/* Going live in three steps, before the camera is on (11-12: a tired streamer on a phone could not tell what to do). */
++.gl-steps { list-style: none; display: grid; gap: 6px; width: 100%; max-width: 340px; margin: 4px 0 2px; padding: 0; text-align: left; }
++.gl-steps li { display: flex; align-items: center; gap: 10px; color: var(--text-muted); font-size: 0.9rem; }
++.gl-steps li.is-now { color: var(--text-primary); font-weight: 650; }
++.gl-step-n { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--border); font-size: 0.78rem; font-weight: 700; }
++.gl-steps li.is-now .gl-step-n { border-color: var(--accent); background: var(--accent); color: var(--on-accent, #fff); }
++/* What went wrong and what to tap, in words (broadcast-devices.js _glFixFor): never a raw error. */
++.gl-fix { width: 100%; max-width: 380px; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; text-align: left;
++ border: 1px solid color-mix(in srgb, #f59e0b 45%, var(--border)); background: color-mix(in srgb, #f59e0b 10%, transparent); }
++.gl-fix[hidden] { display: none; }
++.gl-fix-title { display: flex; gap: 8px; align-items: flex-start; margin: 0 0 6px; font-size: 0.95rem; line-height: 1.35; font-weight: 700; color: var(--text-primary); }
++.gl-fix-title i { color: #f59e0b; margin-top: 3px; }
++.gl-fix-steps { margin: 0; padding-left: 22px; display: grid; gap: 4px; font-size: 0.9rem; line-height: 1.4; color: var(--text-secondary); }
@keyframes gl-mark-work { 50% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--accent) 12%, transparent); transform: scale(1.04); } }
.gl-preview.is-allowed::before { content: '● Camera on'; position: absolute; z-index: 2; top: 12px; left: 12px; color: #4ade80; background: rgba(8,12,20,.78); border: 2px solid #4ade80; border-radius: 99px; padding: 6px 10px; font-size: .76rem; font-weight: 750; }
.gl-pv-controls { position: absolute; z-index: 3; left: 50%; bottom: 8px; transform: translateX(-50%); display: flex; gap: 6px; padding: 4px; border-radius: 999px; background: rgba(8,12,20,.7); }
@@@ -3025,6 +3038,11 @@ html[data-ov-motion=off] .bc-clip-name-timer{display: none}
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;
}
++/* Before the camera is on, Go Live says it turns the camera on first (and does): one line under the label. */
++.bc-ws-golive-section .bc-ws-golive-btn:has(.gl-go-sub:not([hidden])) { flex-direction: column; gap: 2px; }
++.gl-go-main { display: inline-flex; align-items: center; gap: 10px; }
++.gl-go-sub { font-size: 0.78rem; font-weight: 600; opacity: .85; }
++.gl-go-sub[hidden] { display: none; }
/* What viewers will get, before the Go Live button (broadcast-workspace.js _wsPreflight). */
.bc-preflight { margin: 12px 0 12px; padding: 10px 12px; border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 12px; background: color-mix(in srgb, var(--accent) 5%, transparent); }
.bc-preflight-h { margin: 0 0 6px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); }
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex a4799680..2068c34d 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -21,7 +21,7 @@ async function refreshDeviceLists() {
const audioSelect = document.getElementById('bc-forceAudio');
if (camSelect) {
const prev = camSelect.value;
-- camSelect.innerHTML = '<option value="default">Default</option>';
++ camSelect.innerHTML = '<option value="default">Automatic</option>';
devices.filter(d => d.kind === 'videoinput').forEach(d => {
const opt = document.createElement('option');
opt.value = d.deviceId;
@@@ -88,8 +88,8 @@ async function populateDeviceLists() {
const camSelect = document.getElementById('bc-forceCamera');
const audioSelect = document.getElementById('bc-forceAudio');
if (!camSelect || !audioSelect) return;
-- camSelect.innerHTML = '<option value="default">Default</option>';
-- audioSelect.innerHTML = '<option value="default">Default</option>';
++ camSelect.innerHTML = '<option value="default">Automatic</option>';
++ audioSelect.innerHTML = '<option value="default">Automatic</option>';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.deviceId;
@@@ -290,10 +290,71 @@ 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';
++ // "your camera" (data-pick-default) until a device is chosen by name: "Picture from Default" said nothing.
++ el.textContent = opt && opt.value !== 'default' ? opt.textContent : (el.dataset.pickDefault || (opt ? opt.textContent : 'Automatic'));
});
}
++/**
++ * What went wrong turning the camera on, in words, and what to tap to fix it (the owner, 11-12: a streamer on a
++ * phone saw "Could not access camera/mic: Could not load camera controls" and "Error: …", and said "I don't
++ * understand"). One box on the card, never a raw error; the button under it says Try again.
++ */
++function _glFixFor(err, mode) {
++ const ua = navigator.userAgent || '';
++ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
++ const android = /Android/.test(ua);
++ const firefox = /Firefox|FxiOS/.test(ua);
++ const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);
++ const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua);
++ const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : 'camera and microphone';
++ const name = err && err.name;
++ if (name === 'NoApi' || inApp && name !== 'NotReadableError') {
++ return { title: 'This browser can\'t use your camera here', steps: inApp
++ ? ['You opened this inside another app (like Discord, Instagram or TikTok). Those can\'t share a camera.', `Tap the ⋮ or ⋯ menu and choose "Open in ${ios ? 'Safari' : 'Chrome'}" (or "Open in browser").`, 'Sign in there if asked, then press Turn on camera again.']
++ : ['Open openvibe.live in Chrome, Safari, Edge or Firefox, then press Turn on camera.'] };
++ }
++ if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError') {
++ const steps = ios && safari ? ['Tap "aA" (or the page icon) at the left of the address bar.', 'Tap Website Settings.', `Set Camera${mode === 'camera_only' ? '' : ' and Microphone'} to Allow.`]
++ : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.']
++ : android && !firefox ? ['Tap the icon at the left of the address bar (a lock or two sliders).', 'Tap Permissions (or Site settings).', `Set Camera${mode === 'camera_only' ? '' : ' and Microphone'} to Allow.`]
++ : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).']
++ : safari ? ['Open Safari\'s menu → Settings for openvibe.live.', `Set Camera${mode === 'camera_only' ? '' : ' and Microphone'} to Allow.`]
++ : ['Click the camera icon at the right end of the address bar (or the lock at the left).', `Choose "Always allow" for the ${what}, then Done.`];
++ return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again below.'] };
++ }
++ if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') {
++ return { title: 'Another app is using your camera', steps: ['Close apps that use the camera, like Zoom, FaceTime, WhatsApp, Instagram or the Camera app (and other tabs with a call).', 'Then press Try again below.'] };
++ }
++ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
++ return { title: `No ${what} found`, steps: [mode === 'mic_only' ? 'Plug in a microphone or headset, then press Try again.' : 'Plug in a camera (or open its privacy cover), then press Try again.', 'Or go live with your screen instead: "Use a screen or an app instead" below.'] };
++ }
++ if (name === 'OverconstrainedError') {
++ return { title: 'That camera can\'t start with these settings', steps: ['Press Try again: it starts with simpler settings.', 'Still stuck? Pick another camera in Camera settings.'] };
++ }
++ if (name === 'TimeoutError') {
++ return { title: 'Your browser didn\'t answer', steps: ['Look for the browser\'s question at the top of the screen and tap Allow.', 'Nothing there? Press Try again below.'] };
++ }
++ return { title: 'The camera didn\'t start', steps: ['Press Try again below.', 'Still stuck? Reload this page, or try Chrome or Safari.'] };
++}
++
++function _glShowFix(fix) {
++ const box = document.getElementById('gl-fix');
++ if (!box) return;
++ if (!fix) { box.hidden = true; box.replaceChildren(); return; }
++ const title = document.createElement('p');
++ title.className = 'gl-fix-title';
++ const icon = document.createElement('i');
++ icon.className = 'fa-solid fa-circle-info';
++ icon.setAttribute('aria-hidden', 'true');
++ title.append(icon, document.createTextNode(fix.title));
++ const list = document.createElement('ol');
++ list.className = 'gl-fix-steps';
++ for (const s of fix.steps) { const li = document.createElement('li'); li.textContent = s; list.append(li); }
++ box.replaceChildren(title, list);
++ box.hidden = false;
++}
++
/**
* 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
@@@ -485,17 +546,18 @@ async function requestMediaPermissions() {
btn.disabled = true;
btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Requesting access...';
}
-- if (dbg) { dbg.style.display = ''; dbg.textContent = 'Requesting permissions...'; }
++ const permMode = _wsState?.selectedMs?.browser_mode || _wsState?.profile?.browserMode || 'camera';
++ _glShowFix(null);
++ if (dbg) dbg.textContent = 'Requesting permissions...';
-- // Feature detection
++ // Feature detection: an in-app browser (Discord, Instagram, TikTok) or a page outside HTTPS has no camera API.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
-- const msg = 'Camera/mic API not available — make sure you\'re using HTTPS';
-- console.warn('[Broadcast]', msg);
-- if (dbg) dbg.textContent = msg;
-- toast(msg, 'error');
++ console.warn('[Broadcast] Camera/mic API not available');
++ _glShowFix(_glFixFor({ name: 'NoApi' }, permMode));
if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }
return;
}
++ let failed = false;
try {
let tempStream;
@@@ -557,32 +619,18 @@ async function requestMediaPermissions() {
window._glCameraAllowed = true;
document.getElementById('gl-preview')?.classList.add('is-allowed');
_glPreviewSync();
-- toast('Camera & microphone access granted', 'success');
++ toast(permMode === 'mic_only' ? 'Your microphone is on. Press Go Live when you are ready.' : 'Your camera is on. Check how you look, then press Go Live.', 'success');
} catch (err) {
-- console.warn('[Broadcast] Permission request failed:', err.message, err.name);
-- const errDetail = `${err.name || 'Error'}: ${err.message}`;
-- if (dbg) { dbg.style.display = ''; dbg.textContent = errDetail; }
-- if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
-- const hint = document.getElementById('bc-perm-hint-text');
-- if (hint) hint.textContent = /firefox/i.test(navigator.userAgent)
-- ? 'Camera blocked. Select the permission icon beside this address, clear the block, then try again.'
-- : /safari/i.test(navigator.userAgent) && !/chrome/i.test(navigator.userAgent)
-- ? 'Camera blocked. Open this site’s camera and microphone settings in Safari, allow access, then try again.'
-- : 'Camera blocked. Select the lock or camera icon beside this address, allow access, then try again.';
-- toast('Camera/mic permission denied — check your browser settings or tap the lock icon in the address bar', 'error');
-- } else if (err.name === 'NotFoundError') {
-- toast('No camera or microphone found on this device', 'error');
-- } else if (err.name === 'NotReadableError') {
-- toast('Camera/mic in use by another app — close other camera/video apps and try again', 'error');
-- } else if (err.name === 'OverconstrainedError') {
-- toast('Camera does not support the requested settings — try a different camera', 'error');
-- } else {
-- toast('Could not access camera/mic: ' + err.message, 'error');
-- }
++ // The details stay in the console for us; the person reads what happened and what to tap (_glFixFor).
++ console.warn('[Broadcast] Permission request failed:', err.name, err.message);
++ if (dbg) dbg.textContent = `${err.name || 'Error'}: ${err.message}`;
++ failed = true;
++ _glShowFix(_glFixFor(err, permMode));
++ document.getElementById('gl-fix')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} finally {
if (btn) {
btn.disabled = false;
-- btn.innerHTML = btnOrigText;
++ btn.innerHTML = failed ? '<i class="fa-solid fa-rotate-right" aria-hidden="true"></i><span id="bc-perm-btn-text">Try again</span>' : btnOrigText;
}
}
}
@@@ -591,7 +639,7 @@ function _populateCreateDeviceDropdowns(devices) {
const camSelect = document.getElementById('bc-create-camera');
const audioSelect = document.getElementById('bc-create-audio');
if (camSelect) {
-- camSelect.innerHTML = '<option value="default">Default</option>';
++ camSelect.innerHTML = '<option value="default">Automatic</option>';
for (const c of _bcCameraChoices(devices)) {
const opt = document.createElement('option');
opt.value = c.deviceId;
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 61f24b34..c5b20bce 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -917,7 +917,7 @@
const onlyMic = (_wsState?.selectedMs?.browser_mode || _wsState?.profile?.browserMode) === 'mic_only';
dock.querySelector('.bcs-golive-btn i').className = onlyMic ? 'fa-solid fa-microphone' : 'fa-solid fa-video';
dock.querySelector('.bcs-golive-t').textContent = onlyMic ? 'Turn on microphone' : 'Turn on camera';
-- dock.querySelector('.bcs-golive-where').textContent = onlyMic ? 'Microphone off · you choose when to start' : 'Camera off · you choose when to start';
++ dock.querySelector('.bcs-golive-where').textContent = 'Step 1 of 3 · your browser asks, tap Allow';
dock.querySelector('.bcs-golive-btn').disabled = false;
} else if (mode === 'resume') {
const s = liveMine[0];
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 05765b2e..45efc2c5 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -379,15 +379,23 @@ function _wsRenderPanel() {
<div id="bc-ws-device-wrap">
<div id="bc-perm-request" class="bc-perm-request gl-allow">
<span class="gl-allow-mark ov-mark" data-size="48" data-tone="brand" aria-hidden="true"></span>
-- <strong id="gl-allow-title">Your camera is off</strong>
++ <strong id="gl-allow-title">${browserMode === 'mic_only' ? 'Turn on your microphone to go live' : 'Turn on your camera to go live'}</strong>
<p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
-- <span id="bc-perm-hint-text">You choose when this browser may use your camera and microphone.</span></p>
++ <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>
++ <!-- Three steps, ticked as they happen (the owner, 11-12: a streamer on a phone, "I don't understand …
++ my brain too tired to figure it out"). The fix box says what went wrong and what to tap. -->
++ <ol class="gl-steps" id="gl-steps" aria-label="Going live in three steps">
++ <li class="is-now" data-step="1"><span class="gl-step-n" aria-hidden="true">1</span><span>${browserMode === 'mic_only' ? 'Turn on your microphone' : 'Turn on your camera'}</span></li>
++ <li data-step="2"><span class="gl-step-n" aria-hidden="true">2</span><span>${browserMode === 'mic_only' ? 'Check the sound meter moves' : 'Check how you look and sound'}</span></li>
++ <li data-step="3"><span class="gl-step-n" aria-hidden="true">3</span><span>Press Go Live</span></li>
++ </ol>
++ <div class="gl-fix" id="gl-fix" role="alert" hidden></div>
<button id="bc-perm-btn" class="btn btn-primary" type="button"
onclick="requestMediaPermissions()">
<i class="fa-solid fa-video" aria-hidden="true"></i><span id="bc-perm-btn-text">Turn on camera</span>
</button>
<button type="button" class="gl-allow-other" onclick="_glOtherWays()">Use a screen or an app instead</button>
-- <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" hidden></p>
</div>
<div id="bc-device-selects" style="display:none">
<div class="gl-device-sheet-head"><strong>Camera settings</strong><button type="button" onclick="_glCloseDeviceSheet()" aria-label="Close camera settings"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>
@@@ -396,10 +404,10 @@ function _wsRenderPanel() {
<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>
++ <span class="bc-setup-val" data-pick-for="bc-create-camera" data-pick-default="Automatic">Automatic</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>
++ <option value="default">Automatic</option>
</select>
</div>
</div>
@@@ -408,10 +416,10 @@ function _wsRenderPanel() {
<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>
++ <span class="bc-setup-val" data-pick-for="bc-create-audio" data-pick-default="Automatic">Automatic</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>
++ <option value="default">Automatic</option>
</select>
</div>
</div>
@@@ -432,14 +440,15 @@ function _wsRenderPanel() {
<select id="bc-screenSystemAudio-create" style="display:none"><option value="include" selected>Include</option><option value="auto">Auto</option><option value="exclude">Exclude</option></select>
<select id="bc-screenSelfBrowser-create" style="display:none"><option value="exclude" selected>Exclude</option><option value="include">Include</option></select>
<select id="bc-screenSurfaceSwitching-create" style="display:none"><option value="include" selected>Allow</option><option value="exclude">Disallow</option></select>
-- <select id="bc-screen-audio" style="display:none"><option value="default">Default</option></select>
-- <select id="bc-screen-camera" style="display:none"><option value="default">Default</option></select>
++ <select id="bc-screen-audio" style="display:none"><option value="default">Automatic</option></select>
++ <select id="bc-screen-camera" style="display:none"><option value="default">Automatic</option></select>
${_wsPreflight(browserMode)}
-- <button class="btn btn-primary btn-lg bc-ws-golive-btn"
++ <button class="btn btn-primary btn-lg bc-ws-golive-btn${browserMode !== 'screen' && !window._glCameraAllowed ? ' is-waiting' : ''}"
onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">
-- <i class="fa-solid fa-tower-broadcast"></i> Go Live
++ <span class="gl-go-main"><i class="fa-solid fa-tower-broadcast"></i> Go Live</span>
++ <small class="gl-go-sub" id="gl-go-sub"${browserMode !== 'screen' && !window._glCameraAllowed ? '' : ' hidden'}>${browserMode === 'mic_only' ? 'Turns on your microphone first' : 'Turns on your camera first'}</small>
</button>
${browserMode === 'screen' ? '' : `<button type="button" class="gl-test-btn" id="gl-test-btn" onclick="_glTestPrivately()">
<i class="fa-solid fa-circle-dot" aria-hidden="true"></i><span id="gl-test-label">Test privately</span><small>10 seconds, only on this device</small>
@@@ -1062,17 +1071,69 @@ function _glCopyLink() {
const _glPv = { stream: null, key: '', starting: false, timer: 0, raf: 0, ctx: null, hold: 0, failed: '', stopMeter: null };
const _GL_PV_OFF = 'ov_gl_preview_off';
let _glCoreLoad;
++/**
++ * The camera kit's capture core, kept here as well: the same constraints, open, devices and meter as OpenVibe.Cam's
++ * cam-kit.js core, used when the kit cannot load (a blocked or slow openvibe.cam, an extension, a stale cache). From
++ * 11-09 to 11-12 the page's policy refused the kit and nobody could turn a camera on; the camera must never depend on
++ * a second site answering.
++ */
++const _glLocalCore = Object.freeze({
++ devices: () => navigator.mediaDevices.enumerateDevices(),
++ constraints: (o = {}) => {
++ const height = Number(o.resolution) || 720;
++ return {
++ video: o.video === false ? false : {
++ facingMode: { ideal: o.facing || 'user' },
++ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
++ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
++ },
++ audio: o.audio === false ? false : o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true,
++ };
++ },
++ open: (o) => navigator.mediaDevices.getUserMedia(_glLocalCore.constraints(o)),
++ stop: (stream) => { try { stream?.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },
++ network: () => {
++ if (navigator.onLine === false) return 'offline';
++ const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
++ return c && (/2g|3g/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5)) ? 'weak' : 'good';
++ },
++ meter: (stream, onLevel) => {
++ const AC = window.AudioContext || window.webkitAudioContext;
++ if (!AC || !stream.getAudioTracks().length) return () => {};
++ const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512);
++ let frame = 0, closed = false;
++ analyser.fftSize = 512;
++ ctx.createMediaStreamSource(stream).connect(analyser);
++ if (ctx.state === 'suspended') {
++ ctx.resume().catch(() => {});
++ document.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true });
++ }
++ const tick = () => {
++ if (closed) return;
++ analyser.getByteTimeDomainData(data);
++ let sum = 0;
++ for (let i = 0; i < data.length; i++) { const d = (data[i] - 128) / 128; sum += d * d; }
++ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
++ frame = requestAnimationFrame(tick);
++ };
++ frame = requestAnimationFrame(tick);
++ return () => { closed = true; cancelAnimationFrame(frame); ctx.close().catch(() => {}); };
++ },
++});
function _glCamCore() {
if (window.OpenVibeCam?.core) return Promise.resolve(window.OpenVibeCam.core);
-- if (!_glCoreLoad) _glCoreLoad = new Promise((resolve, reject) => {
++ if (!_glCoreLoad) _glCoreLoad = new Promise((resolve) => {
const script = document.createElement('script');
// The query names this version (the start of its hash): a new Cam release is a new cache key, so Cloudflare never
// hands an older copy to this integrity check (it served one for a day after Cam #116, 11-09).
script.src = 'https://openvibe.cam/embed/cam-kit.js?v=yqqjh1FiP7a4';
script.integrity = 'sha384-yqqjh1FiP7a4l9uelJHaryd8Y5IwnHVbqjt1d0AXbgCcytI9Z5MMmItFqbwB4YZG';
script.crossOrigin = 'anonymous';
-- script.onload = () => window.OpenVibeCam?.core ? resolve(window.OpenVibeCam.core) : reject(new Error('Camera controls unavailable'));
-- script.onerror = () => reject(new Error('Could not load camera controls'));
++ // Five seconds at most: a kit that never answers must not keep the camera waiting.
++ const fallback = (why) => { console.warn('[Broadcast] camera kit unavailable, using the built-in core:', why); resolve(_glLocalCore); };
++ const timer = setTimeout(() => fallback('timeout'), 5000);
++ script.onload = () => { clearTimeout(timer); window.OpenVibeCam?.core ? resolve(window.OpenVibeCam.core) : fallback('no core'); };
++ script.onerror = () => { clearTimeout(timer); fallback('load error'); };
document.head.append(script);
});
return _glCoreLoad;
@@@ -1119,7 +1180,7 @@ function _glPreviewHTML(mode) {
: '<video id="gl-preview-video" muted playsinline autoplay aria-label="Your camera, as viewers will see it"></video>'}
<p class="gl-pv-note" id="gl-pv-note">${audioOnly ? '' : 'Allow the camera below to see yourself here.'}</p>
<div class="gl-pv-chips">
-- ${audioOnly ? '' : '<span class="gl-pv-chip"><i class="fa-solid fa-video" aria-hidden="true"></i><span data-pick-for="bc-create-camera">Default</span></span>'}
++ ${audioOnly ? '' : '<span class="gl-pv-chip"><i class="fa-solid fa-video" aria-hidden="true"></i><span data-pick-for="bc-create-camera" data-pick-default="Your camera">Your camera</span></span>'}
${mode === 'camera_only' ? '' : '<span class="gl-pv-chip gl-pv-mic" title="Your microphone level"><i class="fa-solid fa-microphone" aria-hidden="true"></i><span class="gl-pv-lvl" aria-hidden="true"><i id="gl-pv-lvl"></i></span></span>'}
<span class="gl-pv-chip" id="gl-pv-net" hidden></span>
</div>
@@@ -1239,8 +1300,18 @@ function _glPreviewNetwork() {
chip.innerHTML = text ? `<i class="fa-solid fa-signal" aria-hidden="true"></i><span>${esc(text)}</span>` : '';
}
++/** The Go Live button's line under it: "Turns on your camera first" until the camera is on. */
++function _glSyncGoLive() {
++ const btn = document.getElementById('bc-ws-golive-btn');
++ const sub = document.getElementById('gl-go-sub');
++ const waiting = !window._glCameraAllowed && (_wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera') !== 'screen';
++ btn?.classList.toggle('is-waiting', waiting);
++ if (sub) sub.hidden = !waiting;
++}
++
function _glPreviewSync() {
document.getElementById('gl-preview')?.classList.toggle('is-allowed', !!window._glCameraAllowed);
++ _glSyncGoLive();
const want = _glPreviewWant();
_glPreviewNetwork();
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
@@@ -1661,8 +1732,9 @@ function _wsPickHint(kind, id) {
* [data-pick-for] here is kept current by _bcSyncPicks (broadcast-devices.js), like the card's rows.
*/
function _wsPreflight(mode) {
-- const cam = '<b data-pick-for="bc-create-camera">Default</b>';
-- const mic = '<b data-pick-for="bc-create-audio">Default</b>';
++ // Until a device is picked by name, the line says whose it is ("your camera"), never the select's "Default".
++ const cam = '<b data-pick-for="bc-create-camera" data-pick-default="your camera">your camera</b>';
++ const mic = '<b data-pick-for="bc-create-audio" data-pick-default="your microphone">your microphone</b>';
const rows = mode === 'mic_only' ? [['fa-microphone', `Sound from ${mic}`], ['fa-eye-slash', 'No picture']]
: mode === 'camera_only' ? [['fa-video', `Picture from ${cam}`], ['fa-volume-xmark', 'No sound']]
: mode === 'screen' ? [['fa-display', 'Your screen, a window or a tab: your browser asks which when you press Go Live'], ['fa-microphone', 'Your microphone and camera bubble, as set above']]
@@@ -1679,10 +1751,10 @@ function _wsPreflight(mode) {
}
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.'
++ return mode === 'mic_only' ? 'Your browser will ask first: tap Allow. Nobody hears you until you press Go Live.'
++ : mode === 'camera_only' ? 'Your browser will ask first: tap Allow. Nobody sees you until you press Go Live.'
: mode === 'screen' ? 'Your browser asks which screen to share when you go live.'
-- : 'Let your browser use the camera and microphone to choose them.';
++ : 'Your browser will ask first: tap Allow. Nobody sees or hears you until you press Go Live.';
}
// The cards are buttons in a radio group: a tap or a key chooses one, and a screen reader says which is chosen.
@@@ -2761,7 +2833,7 @@ function _wsPopulateInlineDevices(selectId, devices) {
const sel = document.getElementById(selectId);
if (!sel) return;
const currentVal = sel.value;
-- sel.innerHTML = '<option value="default">Default</option>' +
++ sel.innerHTML = '<option value="default">Automatic</option>' +
devices.map(d => `<option value="${esc(d.deviceId)}">${esc(d.label || d.deviceId)}</option>`).join('');
if (currentVal) sel.value = currentVal;
}
@@@ -2779,8 +2851,9 @@ async function goLiveFromWorkspace() {
return;
}
if ((_wsState.selectedMs.streaming_method || 'browser') === 'browser' && (_wsState.selectedMs.browser_mode || _wsState.profile?.browserMode || 'camera') !== 'screen' && !window._glCameraAllowed) {
-- document.getElementById('bc-perm-btn')?.focus();
-- toast('Turn on camera before going live', 'info');
++ // The big button always does the next step: here, step 1. The person sees themselves first, then presses again.
++ document.getElementById('bc-perm-request')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
++ if (typeof requestMediaPermissions === 'function') await requestMediaPermissions();
return;
}
ddiff --git a/test/golive-camera-help.test.js b/test/golive-camera-help.test.js
nnew file mode 100644
iindex 00000000..b72090f6
---- /dev/null
++++ b/test/golive-camera-help.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * Turning the camera on to go live from a browser (broadcast-devices.js requestMediaPermissions, _glFixFor;
++ * broadcast-workspace.js the camera card, _glCamCore). The owner, 11-12: a streamer on a phone saw "Could not access
++ * camera/mic: Could not load camera controls" and "Error: Could not load camera controls" and said "I don't
++ * understand … my brain too tired to figure it out". Now:
++ * - the camera never waits on openvibe.cam: when the kit cannot load, the same core runs from this page;
++ * - the card says what is about to happen and the three steps;
++ * - a failure says what happened and what to tap, for the browser the person is in, and never a raw error;
++ * - the Go Live button turns the camera on first instead of refusing.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++const devices = read('public/js/broadcast-devices.js');
++const workspace = read('public/js/broadcast-workspace.js');
++
++// _glFixFor, run under each browser.
++const start = devices.indexOf('function _glFixFor(');
++const end = devices.indexOf('\nfunction _glShowFix', start);
++assert.ok(start > 0 && end > start, '_glFixFor is there');
++const fixUnder = (ua, touch = 0) => new Function('navigator', `${devices.slice(start, end)}; return _glFixFor;`)({ userAgent: ua, maxTouchPoints: touch });
++const UA = {
++ androidChrome: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Mobile Safari/537.36',
++ iphoneSafari: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
++ instagram: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 350.0',
++ desktopChrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36',
++ firefox: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0',
++};
++const text = (fix) => `${fix.title} ${fix.steps.join(' ')}`;
++const denied = { name: 'NotAllowedError', message: 'Permission denied' };
++
++let fix = fixUnder(UA.androidChrome)(denied, 'camera');
++assert.match(fix.title, /said no to the camera and microphone/);
++assert.match(text(fix), /Permissions/, 'Android Chrome: the site settings under the address bar icon');
++assert.match(text(fix), /Try again/);
++assert.match(text(fixUnder(UA.iphoneSafari)(denied, 'camera')), /aA/, 'iPhone Safari: aA, Website Settings');
++assert.match(text(fixUnder(UA.firefox)(denied, 'camera')), /line through it/, 'Firefox: the crossed-out camera icon');
++assert.match(text(fixUnder(UA.desktopChrome)(denied, 'camera')), /Always allow/, 'desktop Chrome: Always allow');
++assert.match(fixUnder(UA.androidChrome)(denied, 'mic_only').title, /said no to the microphone$/, 'a microphone-only slot names only the microphone');
++
++fix = fixUnder(UA.instagram)({ name: 'NoApi' }, 'camera');
++assert.match(text(fix), /inside another app/);
++assert.match(text(fix), /Open in Safari/, 'an in-app browser on an iPhone: open it in Safari');
++assert.match(text(fixUnder(UA.androidChrome)({ name: 'NotReadableError' }, 'camera')), /Another app is using your camera/);
++assert.match(text(fixUnder(UA.androidChrome)({ name: 'NotFoundError' }, 'camera')), /screen instead/, 'no camera: the screen is the way out');
++assert.match(fixUnder(UA.androidChrome)(new Error('Could not load camera controls'), 'camera').title, /didn't start/);
++for (const ua of Object.values(UA)) {
++ for (const name of ['NotAllowedError', 'NotReadableError', 'NotFoundError', 'OverconstrainedError', 'TimeoutError', 'NoApi', 'TypeError']) {
++ const t = text(fixUnder(ua)({ name, message: 'raw detail' }, 'camera'));
++ assert.ok(!/raw detail|Error\b|getUserMedia|API/.test(t), `${name}: words for people, never the raw error (${t.slice(0, 80)})`);
++ }
++}
++
++// The failure shows in the card, never as raw text; the button under it says Try again.
++assert.ok(devices.includes('_glShowFix(_glFixFor(err, permMode))'), 'a failure fills the card\'s fix box');
++assert.ok(!/toast\('Could not access camera\/mic/.test(devices), 'no raw "Could not access camera/mic" toast');
++assert.ok(devices.includes("'Try again'") || devices.includes('>Try again<'), 'the button says Try again after a failure');
++assert.ok(workspace.includes('<p id="bc-perm-debug" hidden></p>'), 'the debug line is never shown to people');
++
++// The card: what is about to happen and the three steps.
++assert.ok(workspace.includes('Your browser will ask first: tap Allow.'), 'the card says the browser will ask');
++assert.ok(workspace.includes('id="gl-steps"') && workspace.includes('Press Go Live'), 'the three steps');
++assert.ok(workspace.includes('id="gl-fix"'), 'the fix box');
++
++// The camera never waits on openvibe.cam.
++const core = workspace.slice(workspace.indexOf('function _glCamCore()'), workspace.indexOf('function _glOtherWays()'));
++assert.ok(core.includes('resolve(_glLocalCore)') && !core.includes('reject('), 'a kit that cannot load falls back to the built-in core');
++assert.ok(core.includes('setTimeout(() => fallback'), 'and a kit that never answers too');
++for (const m of ['devices', 'constraints', 'open', 'stop', 'network', 'meter']) assert.ok(new RegExp(`\\b${m}: `).test(workspace.slice(workspace.indexOf('const _glLocalCore'), workspace.indexOf('function _glCamCore()'))), `the built-in core has ${m}`);
++
++// Go Live turns the camera on first instead of refusing.
++const go = workspace.slice(workspace.indexOf('async function goLiveFromWorkspace()'), workspace.indexOf('await _wsFlushSave();', workspace.indexOf('async function goLiveFromWorkspace()')));
++assert.ok(go.includes('await requestMediaPermissions()') && !go.includes("toast('Turn on camera before going live'"), 'Go Live does step 1 when the camera is off');
++assert.ok(workspace.includes('Turns on your camera first'), 'and says so under its label');
++console.log('golive-camera-help: plain help for every camera failure, a camera that never waits on another site, Go Live that does the next step');
ddiff --git a/test/golive-preflight.test.js b/test/golive-preflight.test.js
iindex d3310d92..561fc4ab 100644
---- a/test/golive-preflight.test.js
++++ b/test/golive-preflight.test.js
@@@ -15,7 +15,9 @@ assert.ok(start > 0 && end > start, 'the function is there');
const preflight = new Function(`${src.slice(start, end)}; return _wsPreflight;`)();
const cam = preflight('camera');
--assert.ok(cam.includes('Picture from <b data-pick-for="bc-create-camera">') && cam.includes('Sound from <b data-pick-for="bc-create-audio">'), 'camera: the chosen devices by name');
++assert.ok(cam.includes('Picture from <b data-pick-for="bc-create-camera"') && cam.includes('Sound from <b data-pick-for="bc-create-audio"'), 'camera: the chosen devices by name');
++// Until a device is chosen by name it is "your camera" / "your microphone" (11-12: "Picture from Default" said nothing).
++assert.ok(cam.includes('>your camera</b>') && cam.includes('>your microphone</b>') && !cam.includes('>Default<'), 'never "Default"');
assert.ok(cam.includes('you can mute, turn the camera off or switch cameras') && cam.includes('Sharing your screen instead means a new stream'), 'what can change once live, and what cannot');
assert.ok(cam.includes('Works in Chrome, Edge, Firefox and Safari.'), 'the browsers');
assert.ok(preflight('screen').includes('your browser asks which when you press Go Live'), 'screen: what is captured is picked at the button');
@@@ -24,4 +26,5 @@ assert.ok(preflight('camera_only').includes('No sound') && !preflight('camera_on
assert.ok(src.includes('${_wsPreflight(browserMode)}'), 'it sits in the Go Live section');
const syncer = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-devices.js'), 'utf8');
assert.ok(syncer.includes("document.querySelectorAll('[data-pick-for]')"), 'and _bcSyncPicks keeps every [data-pick-for] current');
++assert.ok(syncer.includes('el.dataset.pickDefault'), 'and keeps "your camera" while the choice is the browser\'s own');
console.log('golive-preflight: what viewers will get, said before the button');