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)
Changes
@@@ -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); }@@@ -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;@@@ -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];@@@ -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;}nnew file mode 100644@@@ -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');@@@ -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_onassert.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');
@@ -2999,6 +2999,19 @@ html[data-ov-motion=off] .bc-clip-name-timer{display: none}
@@ -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 { justify-content: center; flex: none; }
.gl-allow .bc-perm-hint i { display: 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; }
.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); } }
@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-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); }
.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}
@@ -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%;
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;
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). */
/* 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 { 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); }
.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); }
@@ -21,7 +21,7 @@ async function refreshDeviceLists() {
@@ -21,7 +21,7 @@ async function refreshDeviceLists() {
const audioSelect = document.getElementById('bc-forceAudio');
const audioSelect = document.getElementById('bc-forceAudio');
if (camSelect) {
if (camSelect) {
const prev = camSelect.value;
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 => {
devices.filter(d => d.kind === 'videoinput').forEach(d => {
const opt = document.createElement('option');
const opt = document.createElement('option');
opt.value = d.deviceId;
opt.value = d.deviceId;
@@ -88,8 +88,8 @@ async function populateDeviceLists() {
@@ -88,8 +88,8 @@ async function populateDeviceLists() {
const camSelect = document.getElementById('bc-forceCamera');
const camSelect = document.getElementById('bc-forceCamera');
const audioSelect = document.getElementById('bc-forceAudio');
const audioSelect = document.getElementById('bc-forceAudio');
if (!camSelect || !audioSelect) return;
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 => {
devices.forEach(d => {
const opt = document.createElement('option');
const opt = document.createElement('option');
opt.value = d.deviceId;
opt.value = d.deviceId;
@@ -290,10 +290,71 @@ function _bcSyncPicks() {
@@ -290,10 +290,71 @@ function _bcSyncPicks() {
document.querySelectorAll('[data-pick-for]').forEach((el) => {
document.querySelectorAll('[data-pick-for]').forEach((el) => {
const sel = document.getElementById(el.dataset.pickFor);
const sel = document.getElementById(el.dataset.pickFor);
const opt = sel && sel.selectedOptions && sel.selectedOptions[0];
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
* 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
* front or back camera … remember whichever you last had selected"; then "the camera design conflicts with the
@@ -485,17 +546,18 @@ async function requestMediaPermissions() {
@@ -485,17 +546,18 @@ async function requestMediaPermissions() {
btn.disabled = true;
btn.disabled = true;
btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Requesting access...';
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) {
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; }
if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }
return;
return;
}
}
+ let failed = false;
try {
try {
let tempStream;
let tempStream;
@@ -557,32 +619,18 @@ async function requestMediaPermissions() {
@@ -557,32 +619,18 @@ async function requestMediaPermissions() {
window._glCameraAllowed = true;
window._glCameraAllowed = true;
document.getElementById('gl-preview')?.classList.add('is-allowed');
document.getElementById('gl-preview')?.classList.add('is-allowed');
_glPreviewSync();
_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) {
} 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 {
} finally {
if (btn) {
if (btn) {
btn.disabled = false;
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) {
@@ -591,7 +639,7 @@ function _populateCreateDeviceDropdowns(devices) {
const camSelect = document.getElementById('bc-create-camera');
const camSelect = document.getElementById('bc-create-camera');
const audioSelect = document.getElementById('bc-create-audio');
const audioSelect = document.getElementById('bc-create-audio');
if (camSelect) {
if (camSelect) {
- camSelect.innerHTML = '<option value="default">Default</option>';
+ camSelect.innerHTML = '<option value="default">Automatic</option>';
for (const c of _bcCameraChoices(devices)) {
for (const c of _bcCameraChoices(devices)) {
const opt = document.createElement('option');
const opt = document.createElement('option');
opt.value = c.deviceId;
opt.value = c.deviceId;
@@ -917,7 +917,7 @@
@@ -917,7 +917,7 @@
const onlyMic = (_wsState?.selectedMs?.browser_mode || _wsState?.profile?.browserMode) === 'mic_only';
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-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-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;
dock.querySelector('.bcs-golive-btn').disabled = false;
} else if (mode === 'resume') {
} else if (mode === 'resume') {
const s = liveMine[0];
const s = liveMine[0];
@@ -379,15 +379,23 @@ function _wsRenderPanel() {
@@ -379,15 +379,23 @@ function _wsRenderPanel() {
<div id="bc-ws-device-wrap">
<div id="bc-ws-device-wrap">
<div id="bc-perm-request" class="bc-perm-request gl-allow">
<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>
<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>
<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"
<button id="bc-perm-btn" class="btn btn-primary" type="button"
onclick="requestMediaPermissions()">
onclick="requestMediaPermissions()">
<i class="fa-solid fa-video" aria-hidden="true"></i><span id="bc-perm-btn-text">Turn on camera</span>
<i class="fa-solid fa-video" aria-hidden="true"></i><span id="bc-perm-btn-text">Turn on camera</span>
</button>
</button>
<button type="button" class="gl-allow-other" onclick="_glOtherWays()">Use a screen or an app instead</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>
<div id="bc-device-selects" style="display:none">
<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>
<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() {
@@ -396,10 +404,10 @@ function _wsRenderPanel() {
<div class="bc-setup-row">
<div class="bc-setup-row">
<span class="bc-setup-label"><i class="fa-solid fa-camera" aria-hidden="true"></i>Camera</span>
<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">
<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>
<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">
<select id="bc-create-camera" class="bc-setup-native" aria-label="Camera">
- <option value="default">Default</option>
+ <option value="default">Automatic</option>
</select>
</select>
</div>
</div>
</div>
</div>
@@ -408,10 +416,10 @@ function _wsRenderPanel() {
@@ -408,10 +416,10 @@ function _wsRenderPanel() {
<div class="bc-setup-row">
<div class="bc-setup-row">
<span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>
<span class="bc-setup-label"><i class="fa-solid fa-microphone" aria-hidden="true"></i>Microphone</span>
<div class="bc-setup-pick">
<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>
<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">
<select id="bc-create-audio" class="bc-setup-native" aria-label="Microphone">
- <option value="default">Default</option>
+ <option value="default">Automatic</option>
</select>
</select>
</div>
</div>
</div>
</div>
@@ -432,14 +440,15 @@ function _wsRenderPanel() {
@@ -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-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-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-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)}
${_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">
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>
</button>
${browserMode === 'screen' ? '' : `<button type="button" class="gl-test-btn" id="gl-test-btn" onclick="_glTestPrivately()">
${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>
<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() {
@@ -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 _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';
const _GL_PV_OFF = 'ov_gl_preview_off';
let _glCoreLoad;
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() {
function _glCamCore() {
if (window.OpenVibeCam?.core) return Promise.resolve(window.OpenVibeCam.core);
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');
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
// 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).
// 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.src = 'https://openvibe.cam/embed/cam-kit.js?v=yqqjh1FiP7a4';
script.integrity = 'sha384-yqqjh1FiP7a4l9uelJHaryd8Y5IwnHVbqjt1d0AXbgCcytI9Z5MMmItFqbwB4YZG';
script.integrity = 'sha384-yqqjh1FiP7a4l9uelJHaryd8Y5IwnHVbqjt1d0AXbgCcytI9Z5MMmItFqbwB4YZG';
script.crossOrigin = 'anonymous';
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);
document.head.append(script);
});
});
return _glCoreLoad;
return _glCoreLoad;
@@ -1119,7 +1180,7 @@ function _glPreviewHTML(mode) {
@@ -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>'}
: '<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>
<p class="gl-pv-note" id="gl-pv-note">${audioOnly ? '' : 'Allow the camera below to see yourself here.'}</p>
<div class="gl-pv-chips">
<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>'}
${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>
<span class="gl-pv-chip" id="gl-pv-net" hidden></span>
</div>
</div>
@@ -1239,8 +1300,18 @@ function _glPreviewNetwork() {
@@ -1239,8 +1300,18 @@ function _glPreviewNetwork() {
chip.innerHTML = text ? `<i class="fa-solid fa-signal" aria-hidden="true"></i><span>${esc(text)}</span>` : '';
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() {
function _glPreviewSync() {
document.getElementById('gl-preview')?.classList.toggle('is-allowed', !!window._glCameraAllowed);
document.getElementById('gl-preview')?.classList.toggle('is-allowed', !!window._glCameraAllowed);
+ _glSyncGoLive();
const want = _glPreviewWant();
const want = _glPreviewWant();
_glPreviewNetwork();
_glPreviewNetwork();
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
@@ -1661,8 +1732,9 @@ function _wsPickHint(kind, id) {
@@ -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.
* [data-pick-for] here is kept current by _bcSyncPicks (broadcast-devices.js), like the card's rows.
*/
*/
function _wsPreflight(mode) {
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']]
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 === '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']]
: 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) {
@@ -1679,10 +1751,10 @@ function _wsPreflight(mode) {
}
}
function _wsPermHint(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.'
: 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.
// 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) {
@@ -2761,7 +2833,7 @@ function _wsPopulateInlineDevices(selectId, devices) {
const sel = document.getElementById(selectId);
const sel = document.getElementById(selectId);
if (!sel) return;
if (!sel) return;
const currentVal = sel.value;
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('');
devices.map(d => `<option value="${esc(d.deviceId)}">${esc(d.label || d.deviceId)}</option>`).join('');
if (currentVal) sel.value = currentVal;
if (currentVal) sel.value = currentVal;
}
}
@@ -2779,8 +2851,9 @@ async function goLiveFromWorkspace() {
@@ -2779,8 +2851,9 @@ async function goLiveFromWorkspace() {
return;
return;
}
}
if ((_wsState.selectedMs.streaming_method || 'browser') === 'browser' && (_wsState.selectedMs.browser_mode || _wsState.profile?.browserMode || 'camera') !== 'screen' && !window._glCameraAllowed) {
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;
return;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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');
@@ -15,7 +15,9 @@ assert.ok(start > 0 && end > start, 'the function is there');
@@ -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 preflight = new Function(`${src.slice(start, end)}; return _wsPreflight;`)();
const cam = preflight('camera');
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('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(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');
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
@@ -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');
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');
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("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');
console.log('golive-preflight: what viewers will get, said before the button');