openvibers/openvibe.live Commits
Go live: Live's own capture system (OVCapture), and nothing from openvibe.cam (#847)
Changes
@@@ -258,6 +258,7 @@"/shared/kit-web.js","/js/broadcast-state.js","/js/broadcast-media-setup.js",++ "/js/live-capture.js","/js/broadcast-devices.js","/js/call.js","/js/broadcast-workspace.js",@@@ -295,49 +295,7 @@ function _bcSyncPicks() {});}--/**-- * 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.'] };--}--++/** The fix box on the go-live card: what went wrong and what to tap (the words are OVCapture's 'explain' part). */function _glShowFix(fix) {const box = document.getElementById('gl-fix');if (!box) return;@@@ -553,44 +511,24 @@ async function requestMediaPermissions() {// Feature detection: an in-app browser (Discord, Instagram, TikTok) or a page outside HTTPS has no camera API.if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {console.warn('[Broadcast] Camera/mic API not available');-- _glShowFix(_glFixFor({ name: 'NoApi' }, permMode));++ _glShowFix(OVCapture.explain({ name: 'NoApi' }, permMode));if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }return;}let failed = false;try {-- let tempStream;-- const core = await _glCamCore();++ // Live's own capture system opens it (public/js/live-capture.js: preferred, then separately, then basic, each++ // timed); a refusal stops at once, because asking another way would only ask again.const mode = _wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera';-- // Strategy 1: combined audio+video-- try {-- if (dbg) dbg.textContent = 'Requesting camera + mic...';-- tempStream = await core.open({ audio: mode !== 'camera_only', video: mode !== 'mic_only', facing: window._glCamSettings?.facing || 'user', resolution: window._glCamSettings?.resolution || '720' });-- } catch (firstErr) {-- console.warn('[Broadcast] Combined getUserMedia failed:', firstErr.message, '— trying separately');-- // Strategy 2: separate requests (common on Android)-- if (dbg) dbg.textContent = 'Combined failed, trying separately...';-- let vidStream, audStream;-- try { if (mode !== 'mic_only') vidStream = await _getUserMediaWithTimeout({ video: { facingMode: 'user' } }, 10000); } catch (ve) {-- console.warn('[Broadcast] Video facingMode failed:', ve.message);-- // Strategy 2b: bare minimum video-- try { vidStream = await _getUserMediaWithTimeout({ video: true }, 10000); } catch (ve2) {-- console.warn('[Broadcast] Video-only getUserMedia failed:', ve2.message);-- }-- }-- try { if (mode !== 'camera_only') audStream = await _getUserMediaWithTimeout({ audio: true }, 10000); } catch (ae) { console.warn('[Broadcast] Audio-only getUserMedia failed:', ae.message); }-- if (!vidStream && !audStream) throw new Error('No camera or microphone available');-- tempStream = new MediaStream();-- if (vidStream) vidStream.getTracks().forEach(t => { tempStream.addTrack(t); });-- if (audStream) audStream.getTracks().forEach(t => { tempStream.addTrack(t); });-- }++ if (dbg) dbg.textContent = 'Requesting camera + mic...';++ const tempStream = await OVCapture.open({ mode, facing: window._glCamSettings?.facing || 'user', resolution: window._glCamSettings?.resolution || '720' });const hasVideo = tempStream.getVideoTracks().length > 0;const hasAudio = tempStream.getAudioTracks().length > 0;tempStream.getTracks().forEach(t => t.stop());if (mode !== 'mic_only' && !hasVideo || mode !== 'camera_only' && !hasAudio) throw new DOMException('The requested device was not available', 'NotFoundError');if (dbg) dbg.textContent = 'Enumerating devices...';-- const devices = await core.devices();++ const devices = await OVCapture.devices();_populateCreateDeviceDropdowns(devices);const saved = window._glCamSettings;const camSelect = document.getElementById('bc-create-camera');@@@ -621,11 +559,11 @@ async function requestMediaPermissions() {_glPreviewSync();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) {-- // The details stay in the console for us; the person reads what happened and what to tap (_glFixFor).++ // The details stay in the console for us; the person reads what happened and what to tap (OVCapture.explain).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));++ _glShowFix(OVCapture.explain(err, permMode));document.getElementById('gl-fix')?.scrollIntoView({ behavior: 'smooth', block: 'center' });} finally {if (btn) {@@@ -37,7 +37,7 @@try { localStorage.setItem(MIRROR_KEY, mirror ? '1' : '0'); } catch { /* a private window keeps the default */ }if (window._glCamSettings) {window._glCamSettings.mirror = mirror;-- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mirror }) }).catch(() => {});++ if (window.OVCapture) OVCapture.settings.save({ mirror }); // this device's camera choices (live-capture.js)}refresh();}@@@ -1070,74 +1070,8 @@ function _glCopyLink() {stream starts or Go live is pressed. Turning it off is remembered on this device. ── */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) => {-- 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';-- // 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;--}++// The camera is Live's own: public/js/live-capture.js (window.OVCapture), every part replaceable. The page loaded++// OpenVibe.Cam's cam-kit.js for this until 11-12 (the owner: "we don't want to use openvibe.cam for OpenVibe.Live").function _glOtherWays() {const fold = document.getElementById('gl-how');if (fold) { fold.open = true; fold.scrollIntoView({ behavior: 'smooth', block: 'start' }); }@@@ -1162,8 +1096,8 @@ function _glSheetMirror(wanted) {try { localStorage.setItem('ov_bc_mirror_preview', wanted ? '1' : '0'); } catch {}const cam = document.getElementById('bc-create-camera');const settings = { facing: cam?.selectedOptions[0]?.dataset.facing || 'user', lens: cam?.value || 'main', mic: document.getElementById('bc-create-audio')?.value || 'default', resolution: document.getElementById('bc-ws-resolution')?.value || '720', mirror: wanted };-- window._glCamSettings = settings;-- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});++ // This device's camera choices are kept on this device (OVCapture's 'store' part): device ids are this browser's own.++ window._glCamSettings = OVCapture.settings.save(settings);_glPreviewSync();}@@@ -1244,8 +1178,7 @@ async function _glPreviewStart(key) {_glPreviewStop();const [mode, cam, mic, resolution] = key.split('|');try {-- const core = await _glCamCore();-- const stream = await core.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });++ const stream = await OVCapture.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });// Things moved on while the camera opened: let it go again.if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }_glPv.stream = stream;@@@ -1268,7 +1201,7 @@ async function _glPreviewStart(key) {if (box) box.dataset.state = 'on';const note = document.getElementById('gl-pv-note');if (note) note.textContent = '';-- if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPv.stopMeter = core.meter(stream, (level) => {++ if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPv.stopMeter = OVCapture.meter(stream, (level) => {const bar = document.getElementById('gl-pv-lvl');if (bar) bar.style.transform = `scaleX(${level.toFixed(3)})`;});@@@ -1286,7 +1219,7 @@ async function _glPreviewStart(key) {function _glPreviewNetwork() {const chip = document.getElementById('gl-pv-net');if (!chip) return;-- const shared = window.OpenVibeCam?.core?.network?.();++ const shared = window.OVCapture ? OVCapture.network() : '';const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;let text = '', cls = '';if (shared === 'offline' || navigator.onLine === false) { text = 'Offline'; cls = 'is-bad'; }@@@ -1322,7 +1255,7 @@ function _glPreviewSync() {function _glPreviewWatch() {_glPreviewSync();if (_glPv.timer) return;-- fetch('/api/cam/settings', { credentials: 'same-origin' }).then((r) => r.ok ? r.json() : null).then((saved) => {++ Promise.resolve(OVCapture.settings.load()).then((saved) => {if (!saved) return;window._glCamSettings = saved;const cam = document.getElementById('bc-create-camera');@@@ -1346,8 +1279,7 @@ function _glPreviewWatch() {resolution: document.getElementById('bc-ws-resolution')?.value || '720',mirror: (() => { try { return localStorage.getItem('ov_bc_mirror_preview') === '1'; } catch { return true; } })(),};-- window._glCamSettings = settings;-- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});++ window._glCamSettings = OVCapture.settings.save(settings);_glPreviewSync();});_glPv.timer = setInterval(() => {nnew file mode 100644@@@ -0,0 +1,253 @@++/*++ * Live's capture system: window.OVCapture. Everything the go-live page does with a camera and a microphone, built from++ * parts that can each be replaced, and nothing that talks to another site.++ *++ * The owner, 2026-11-12: "we don't want to use openvibe.cam for OpenVibe.Live, we already had our own custom system for++ * live stream … so long as we can completely customize it and it's implemented in a highly over engineered modular++ * editable way." Until then the page loaded OpenVibe.Cam's cam-kit.js for this, and from 11-09 to 11-12 nobody could++ * turn a camera on when that load was refused.++ *++ * Using it:++ * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. Runs the strategies in order, each with++ * its own time limit; the first that opens wins.++ * OVCapture.devices() → the browser's cameras and microphones.++ * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.++ * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).++ * OVCapture.stop(stream)++ * OVCapture.explain(error, mode) → { title, steps }: what happened and what to tap.++ * OVCapture.settings.load() / .save(partial) → this device's choices: facing, lens, mic, resolution,++ * mirror (the 'store' part; this browser by default).++ *++ * Changing it:++ * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:++ * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));++ * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, enumerate, meter, network,++ * store, explain, timeoutMs.++ * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),++ * .remove(name), .replace(name, strategy). A strategy is { name, open(options, capture) }.++ * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·++ * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings }.++ * Returns off().++ * Every name and every default is in this file; nothing is hidden in a library.++ */++(function () {++ 'use strict';++++ // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────++ const parts = {++ /** What to ask the browser for, from the options (facing, lens, mic, resolution, mode). */++ constraints(o = {}) {++ const height = Number(o.resolution) || 720;++ const 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 },++ };++ const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);++ return { video, audio };++ },++ getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),++ enumerate: () => navigator.mediaDevices.enumerateDevices(),++ meter(stream, onLevel) {++ const AC = window.AudioContext || window.webkitAudioContext;++ if (!AC || !stream || !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(() => {}); };++ },++ network() {++ if (navigator.onLine === false) return 'offline';++ const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;++ if (!c || !(c.effectiveType || c.downlink)) return '';++ return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';++ },++ /** Where this device's camera choices live: this browser, because device ids are this browser's own. */++ store: {++ key: 'ov_capture_settings',++ load() { try { return JSON.parse(localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },++ save(settings) { try { localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },++ },++ explain: null, // set below (explainFailure): the plain words for a failure, by browser++ timeoutMs: 10000,++ };++++ // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────++ const listeners = new Map();++ function on(event, fn) {++ if (!listeners.has(event)) listeners.set(event, new Set());++ listeners.get(event).add(fn);++ return () => listeners.get(event).delete(fn);++ }++ function emit(event, detail) {++ (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });++ }++++ // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────++ const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });++ const merge = (...streams) => {++ const out = new MediaStream();++ streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));++ return out;++ };++ const defaults = [++ {++ name: 'preferred',++ note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',++ open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),++ },++ {++ name: 'separately',++ note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',++ async open(o, c) {++ const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');++ let video = null, audio = null, last = null;++ if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }++ if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }++ if (!video && !audio) throw last || new DOMException('No camera or microphone', 'NotFoundError');++ return merge(video, audio);++ },++ },++ {++ name: 'basic',++ note: 'Any camera and any microphone, nothing asked of them.',++ open: (o, c) => c.part('getUserMedia')(wants(o)),++ },++ ];++ let order = defaults.slice();++ const strategies = {++ list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),++ add(strategy, at = {}) {++ if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');++ order = order.filter((s) => s.name !== strategy.name);++ const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;++ if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);++ },++ remove(name) { order = order.filter((s) => s.name !== name); },++ replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },++ reset() { order = defaults.slice(); },++ };++++ // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.++ const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };++ const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);++ function within(promise, ms, name) {++ let timer;++ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])++ .finally(() => clearTimeout(timer));++ }++++ async function open(options = {}) {++ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw Object.assign(new Error('This browser has no camera here'), { name: 'NoApi' });++ const o = { ...capture.settings.load(), ...options };++ let worst = null;++ for (const s of order) {++ emit('opening', { strategy: s.name, options: o });++ try {++ const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);++ const w = wants(o);++ // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.++ if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {++ stream.getTracks().forEach((t) => t.stop());++ throw new DOMException('The requested device was not available', 'NotFoundError');++ }++ emit('opened', { strategy: s.name, stream });++ return stream;++ } catch (error) {++ worst = heavier(worst, error);++ emit('failed', { strategy: s.name, error });++ // A refusal is the person's answer: asking again in another way would only ask again.++ if (error && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError' || error.name === 'SecurityError')) break;++ }++ }++ emit('gave-up', { error: worst });++ throw worst || new Error('The camera did not open');++ }++++ // ── Plain words for a failure, by browser (the go-live card's fix box). ─────────────────────────────────────────++ function explainFailure(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.'] };++ }++ parts.explain = explainFailure;++++ // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────++ const capture = {++ version: 1,++ part(name) {++ if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);++ return parts[name];++ },++ use(name, impl) {++ const before = capture.part(name);++ parts[name] = impl;++ return before;++ },++ parts: () => Object.keys(parts),++ strategies,++ on,++ open,++ devices: () => parts.enumerate(),++ meter: (stream, onLevel) => parts.meter(stream, onLevel),++ network: () => parts.network(),++ stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },++ explain: (error, mode) => parts.explain(error, mode),++ settings: {++ load: () => ({ facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, ...parts.store.load() }),++ save(partial) {++ const next = { ...capture.settings.load(), ...(partial || {}) };++ parts.store.save(next);++ emit('settings', { settings: next });++ return next;++ },++ },++ };++ window.OVCapture = capture;++})();@@@ -48,16 +48,4 @@ async function createStill({ token, slot, streamId, caption }) {return data;}--async function cameraSettings({ token, method = 'GET', settings }) {-- if (!available()) throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');-- if (!token) throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');-- const res = await fetch(`${CAM_URL}/api/v1/me/camera-settings`, {-- method, headers: { Authorization: `Bearer ${token}`, ...(method === 'PUT' ? { 'Content-Type': 'application/json' } : {}) },-- ...(method === 'PUT' ? { body: JSON.stringify(settings) } : {}), signal: AbortSignal.timeout(TIMEOUT_MS),-- });-- const data = await res.json();-- if (!res.ok) throw new CamApiError(data.detail || 'Could not save camera settings', res.status, data.code);-- return data;--}----module.exports = { CAM_URL, available, createStill, cameraSettings, CamApiError };++module.exports = { CAM_URL, available, createStill, CamApiError };@@@ -21,19 +21,8 @@ const router = express.Router();const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;const SHARE_TEXT_MAX = 500;--// The browser never receives its Network token. Live forwards only the signed-in person's choices to Cam.--router.get('/settings', requireAuth, async (req, res) => {-- try {-- if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });-- return res.json(await cam.cameraSettings({ token: extractToken(req) }));-- } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }--});--router.put('/settings', requireAuth, async (req, res) => {-- try {-- if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });-- return res.json(await cam.cameraSettings({ token: extractToken(req), method: 'PUT', settings: req.body }));-- } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }--});++// The go-live camera's own choices (facing, lens, microphone, resolution, mirror) stay on the device that made them:++// public/js/live-capture.js. They were synced through OpenVibe.Cam until 11-12, when Live's capture became its own.async function liveStream(req, res) {const streamId = req.body && Number(req.body.stream_id);@@@ -391,11 +391,10 @@ app.use(helmet({contentSecurityPolicy: {directives: {defaultSrc: ["'self'"],-- scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com", "https://openvibe.cam"],-- // esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream-- // openvibe.cam: the go-live page's camera kit (cam-kit.js, hash-pinned) and the stylesheet it adds. Without-- // it every browser refused the kit and "Turn on camera" said "Could not load camera controls" (11-09 to 11-12).-- styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "https://openvibe.cam"],++ scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com"],++ // esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream. No other site's script++ // runs the camera: the go-live page's capture is Live's own (public/js/live-capture.js, 11-12).++ styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],// i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them// every queued item renders as a broken image, which is what the media@@@ -422,7 +422,7 @@ function cspReportOnly(html) {if (/type="application\/(ld\+)?json"/i.test(m[1])) continue;hashes.add(`'sha256-${crypto.createHash('sha256').update(m[2], 'utf8').digest('base64')}'`);}-- const scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com https://openvibe.cam";++ const scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com";const policy = [// The shared navbar adds inline speculation rules (same-site prefetch); CSP 3 allows them with their own// keyword, not a hash (11-03: every guest's direct /broadcast load reported one).@@@ -2,9 +2,9 @@// Every script a page adds from another host is allowed by Live's Content Security Policy, both the enforced one// (server/index.js helmet scriptSrc) and the report-only one (server/web/assets.js cspReportOnly).//--// 11-12: the go-live page loads OpenVibe.Cam's camera kit (public/js/broadcast-workspace.js, cam-kit.js with an--// integrity hash), but script-src never named https://openvibe.cam, so every browser refused it and "Turn on camera"--// answered "Could not load camera controls" from 11-09 until this test.++// 11-12: the go-live page loaded OpenVibe.Cam's camera kit while script-src never named https://openvibe.cam, so every++// browser refused it and "Turn on camera" answered "Could not load camera controls" from 11-09. (Since 11-12 the++// camera is Live's own, public/js/live-capture.js, and no page loads a script from openvibe.cam.)// node test/csp-script-hosts.test.jsconst assert = require('assert');const fs = require('fs');@@@ -41,12 +41,12 @@ for (const f of files) {hosts.set(m[1], path.relative(root, f));}}--assert.ok(hosts.has('https://openvibe.cam'), 'the scan finds the camera kit (or the test no longer reads the page)');++assert.ok(hosts.has('https://cdn.jsdelivr.net'), 'the scan finds a known third-party script (pastes.js), so it reads the pages');++assert.ok(!hosts.has('https://openvibe.cam'), 'no page loads a script from OpenVibe.Cam: the camera is Live\'s own');for (const [host, file] of hosts) {const bare = host.replace(/^https:\/\//, ''); // a source may be written with or without its schemeassert.ok(scriptSrc.includes(`"${host}"`) || scriptSrc.includes(`"${bare}"`), `${file} loads a script from ${host}: script-src must allow it`);assert.ok(reportOnly.split(' ').some((x) => x === host || x === bare), `${file} loads a script from ${host}: the report-only script-src-elem must allow it`);}--// The camera kit adds its own stylesheet from the same host.--assert.ok(styleSrc.includes('"https://openvibe.cam"'), 'style-src allows the camera kit\'s stylesheet');++assert.ok(!scriptSrc.includes('openvibe.cam') && !styleSrc.includes('openvibe.cam'), 'and the policy no longer lets one');console.log(`csp script hosts: ${hosts.size} third-party script host(s) allowed`);@@@ -1,10 +1,10 @@'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++ * Turning the camera on to go live from a browser (broadcast-devices.js requestMediaPermissions; live-capture.js, Live's++ * own capture system and its 'explain' part; broadcast-workspace.js the camera card). 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 camera is Live's own (live-capture.js): no other site's script is loaded to open it;* - 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.@@@ -17,11 +17,12 @@ 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 });++// The 'explain' part (live-capture.js explainFailure), run under each browser.++const capture = read('public/js/live-capture.js');++const start = capture.indexOf(' function explainFailure(');++const end = capture.indexOf('\n parts.explain = explainFailure;', start);++assert.ok(start > 0 && end > start, 'explainFailure is there');++const fixUnder = (ua, touch = 0) => new Function('navigator', `${capture.slice(start, end)}; return explainFailure;`)({ 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',@@@ -55,7 +56,7 @@ for (const ua of Object.values(UA)) {}// 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(devices.includes('_glShowFix(OVCapture.explain(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');@@@ -65,11 +66,11 @@ assert.ok(workspace.includes('Your browser will ask first: tap Allow.'), 'the caassert.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}`);++// The camera is Live's own: no other site's kit, and the page opens it through OVCapture.++const stripComments = (t) => t.replace(/\/\/[^\n]*/g, '');++assert.ok(!/openvibe\.cam\/embed|cam-kit\.js|OpenVibeCam/.test(stripComments(workspace)), 'broadcast-workspace.js loads nothing from OpenVibe.Cam');++assert.ok(!/openvibe\.cam\/embed|cam-kit\.js|OpenVibeCam/.test(stripComments(devices)), 'broadcast-devices.js loads nothing from OpenVibe.Cam');++assert.ok(devices.includes('await OVCapture.open(') && workspace.includes('await OVCapture.open('), 'both the permission and the preview open through OVCapture');// 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()')));nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Live's own capture system (public/js/live-capture.js, window.OVCapture), run against a fake browser: the strategies in++ * order and the first that opens wins; a refusal stops at once; a strategy that hangs is timed out; every part can be++ * replaced (and the replaced one called through); strategies can be added, removed and replaced; this device's settings++ * go through the 'store' part; and the module talks to no other site.++ * node test/live-capture.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'live-capture.js'), 'utf8');++assert.ok(!/https?:\/\//.test(source.replace(/^\s*(\/\/|\*|\/\*).*$/gm, '')), 'the module fetches nothing from anywhere');++++class FakeTrack { constructor(kind) { this.kind = kind; this.stopped = false; } stop() { this.stopped = true; } }++class FakeStream {++ constructor(tracks = []) { this.tracks = tracks.slice(); }++ getTracks() { return this.tracks; }++ getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); }++ getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); }++ addTrack(t) { this.tracks.push(t); }++}++class FakeDOMException extends Error { constructor(message, name) { super(message); this.name = name; } }++++function load(gum) {++ const store = new Map();++ const calls = [];++ const navigator = {++ userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile Safari/537.36', maxTouchPoints: 5, onLine: true,++ mediaDevices: { getUserMedia: (c) => { calls.push(c); return gum(c, calls.length); }, enumerateDevices: async () => [{ kind: 'videoinput', deviceId: 'a', label: 'Front' }] },++ };++ const window = {};++ const ctx = vm.createContext({ window, navigator, MediaStream: FakeStream, DOMException: FakeDOMException, setTimeout, clearTimeout, console,++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) }, document: { addEventListener() {} } });++ ctx.globalThis = ctx;++ vm.runInContext(source.replace("(function () {", "(function () { const window = globalThis.window;"), ctx);++ return { capture: window.OVCapture, calls, store };++}++// Values made inside the vm come from another realm: compare them as JSON.++const same = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);++const both = (c) => new FakeStream([...(c.video ? [new FakeTrack('video')] : []), ...(c.audio ? [new FakeTrack('audio')] : [])]);++++(async () => {++ // The preferred way opens it: one call, with the chosen facing and resolution.++ let t = load(async (c) => both(c));++ let s = await t.capture.open({ mode: 'camera', facing: 'environment', resolution: '1080' });++ assert.strictEqual(s.getVideoTracks().length, 1);++ assert.strictEqual(t.calls.length, 1);++ same(t.calls[0].video.facingMode, { ideal: 'environment' });++ assert.strictEqual(t.calls[0].video.height.ideal, 1080);++++ // Both at once refused by the hardware (an Android phone): the camera, then the microphone, each on its own.++ t = load(async (c) => { if (c.video && c.audio) throw new FakeDOMException('busy', 'OverconstrainedError'); return both(c); });++ const seen = [];++ t.capture.on('failed', (e) => seen.push(`failed:${e.strategy}`));++ t.capture.on('opened', (e) => seen.push(`opened:${e.strategy}`));++ s = await t.capture.open({ mode: 'camera' });++ assert.strictEqual(s.getVideoTracks().length + s.getAudioTracks().length, 2);++ same(seen, ['failed:preferred', 'opened:separately']);++++ // A refusal is the person's answer: no second way of asking.++ t = load(async () => { throw new FakeDOMException('denied', 'NotAllowedError'); });++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotAllowedError');++ assert.strictEqual(t.calls.length, 1, 'stopped after the refusal');++ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'camera').title, /said no/);++++ // Several failures: the one that says most about what to do is the one reported.++ t = load(async (c, n) => { throw new FakeDOMException('x', n === 1 ? 'NotFoundError' : 'NotReadableError'); });++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotReadableError');++++ // A strategy that never answers is timed out, and the next one runs.++ t = load(async (c, n) => (n === 1 ? new Promise(() => {}) : both(c)));++ t.capture.use('timeoutMs', 30);++ s = await t.capture.open({ mode: 'camera' });++ assert.ok(s.getVideoTracks().length, 'opened after the timed-out strategy');++++ // Asked for a camera and given none: not opened (and its tracks are stopped).++ t = load(async () => new FakeStream([new FakeTrack('audio')]));++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotFoundError');++++ // Every part can be replaced, and the one it replaced called through.++ t = load(async (c) => both(c));++ const before = t.capture.use('constraints', (o) => ({ ...before(o), audio: { echoCancellation: false } }));++ await t.capture.open({ mode: 'camera' });++ same(t.calls[0].audio, { echoCancellation: false });++ assert.throws(() => t.capture.part('nope'), /no capture part "nope"/);++ assert.ok(['constraints', 'getUserMedia', 'enumerate', 'meter', 'network', 'store', 'explain', 'timeoutMs'].every((p) => t.capture.parts().includes(p)));++++ // Strategies: listed, added before another, removed, replaced.++ t = load(async (c) => both(c));++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);++ let ran = '';++ t.capture.strategies.add({ name: 'mine', open: async (o, c) => { ran = 'mine'; return both({ video: true, audio: true }); } }, { before: 'preferred' });++ same(t.capture.strategies.list().map((x) => x.name), ['mine', 'preferred', 'separately', 'basic']);++ await t.capture.open({ mode: 'camera' });++ assert.strictEqual(ran, 'mine');++ t.capture.strategies.remove('mine');++ t.capture.strategies.replace('basic', { open: async () => both({ video: true, audio: true }) });++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);++ assert.throws(() => t.capture.strategies.add({ name: 'x' }), /a strategy is/);++++ // This device's settings, through the 'store' part, with a 'settings' event.++ t = load(async (c) => both(c));++ let saved = null;++ t.capture.on('settings', (e) => { saved = e.settings; });++ t.capture.settings.save({ mirror: false, resolution: '1080' });++ assert.strictEqual(t.capture.settings.load().resolution, '1080');++ assert.strictEqual(saved.mirror, false);++ assert.ok(t.store.get('ov_capture_settings').includes('1080'), 'kept on this device');++ const mem = {};++ t.capture.use('store', { load: () => mem.v || {}, save: (v) => { mem.v = v; } });++ t.capture.settings.save({ lens: 'back-wide' });++ assert.strictEqual(mem.v.lens, 'back-wide', 'a replaced store is used');++++ // No camera API at all (an in-app browser): a NoApi error the card explains.++ t = load(async (c) => both(c));++ delete t.capture; // reload with no mediaDevices++ const store = new Map();++ const w = {};++ const ctx = vm.createContext({ window: w, navigator: { userAgent: 'Instagram 350', onLine: true }, MediaStream: FakeStream, DOMException: FakeDOMException, setTimeout, clearTimeout, console,++ localStorage: { getItem: (k) => store.get(k) || null, setItem: (k, v) => store.set(k, v) }, document: { addEventListener() {} } });++ ctx.globalThis = ctx;++ vm.runInContext(source.replace("(function () {", "(function () { const window = globalThis.window;"), ctx);++ await assert.rejects(w.OVCapture.open({}), (e) => e.name === 'NoApi');++ assert.match(w.OVCapture.explain({ name: 'NoApi' }, 'camera').steps.join(' '), /inside another app/);++++ console.log('live-capture: strategies in order, refusals stop, time limits, every part and strategy replaceable, settings on this device');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -258,6 +258,7 @@
@@ -258,6 +258,7 @@
"/shared/kit-web.js",
"/shared/kit-web.js",
"/js/broadcast-state.js",
"/js/broadcast-state.js",
"/js/broadcast-media-setup.js",
"/js/broadcast-media-setup.js",
+ "/js/live-capture.js",
"/js/broadcast-devices.js",
"/js/broadcast-devices.js",
"/js/call.js",
"/js/call.js",
"/js/broadcast-workspace.js",
"/js/broadcast-workspace.js",
@@ -295,49 +295,7 @@ function _bcSyncPicks() {
@@ -295,49 +295,7 @@ function _bcSyncPicks() {
});
});
}
}
-/**
- * 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.'] };
-}
-
+/** The fix box on the go-live card: what went wrong and what to tap (the words are OVCapture's 'explain' part). */
function _glShowFix(fix) {
function _glShowFix(fix) {
const box = document.getElementById('gl-fix');
const box = document.getElementById('gl-fix');
if (!box) return;
if (!box) return;
@@ -553,44 +511,24 @@ async function requestMediaPermissions() {
@@ -553,44 +511,24 @@ async function requestMediaPermissions() {
// Feature detection: an in-app browser (Discord, Instagram, TikTok) or a page outside HTTPS has no camera API.
// 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) {
console.warn('[Broadcast] Camera/mic API not available');
console.warn('[Broadcast] Camera/mic API not available');
- _glShowFix(_glFixFor({ name: 'NoApi' }, permMode));
+ _glShowFix(OVCapture.explain({ name: 'NoApi' }, permMode));
if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }
if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }
return;
return;
}
}
let failed = false;
let failed = false;
try {
try {
- let tempStream;
- const core = await _glCamCore();
+ // Live's own capture system opens it (public/js/live-capture.js: preferred, then separately, then basic, each
+ // timed); a refusal stops at once, because asking another way would only ask again.
const mode = _wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera';
const mode = _wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera';
- // Strategy 1: combined audio+video
- try {
- if (dbg) dbg.textContent = 'Requesting camera + mic...';
- tempStream = await core.open({ audio: mode !== 'camera_only', video: mode !== 'mic_only', facing: window._glCamSettings?.facing || 'user', resolution: window._glCamSettings?.resolution || '720' });
- } catch (firstErr) {
- console.warn('[Broadcast] Combined getUserMedia failed:', firstErr.message, '— trying separately');
- // Strategy 2: separate requests (common on Android)
- if (dbg) dbg.textContent = 'Combined failed, trying separately...';
- let vidStream, audStream;
- try { if (mode !== 'mic_only') vidStream = await _getUserMediaWithTimeout({ video: { facingMode: 'user' } }, 10000); } catch (ve) {
- console.warn('[Broadcast] Video facingMode failed:', ve.message);
- // Strategy 2b: bare minimum video
- try { vidStream = await _getUserMediaWithTimeout({ video: true }, 10000); } catch (ve2) {
- console.warn('[Broadcast] Video-only getUserMedia failed:', ve2.message);
- }
- }
- try { if (mode !== 'camera_only') audStream = await _getUserMediaWithTimeout({ audio: true }, 10000); } catch (ae) { console.warn('[Broadcast] Audio-only getUserMedia failed:', ae.message); }
- if (!vidStream && !audStream) throw new Error('No camera or microphone available');
- tempStream = new MediaStream();
- if (vidStream) vidStream.getTracks().forEach(t => { tempStream.addTrack(t); });
- if (audStream) audStream.getTracks().forEach(t => { tempStream.addTrack(t); });
- }
+ if (dbg) dbg.textContent = 'Requesting camera + mic...';
+ const tempStream = await OVCapture.open({ mode, facing: window._glCamSettings?.facing || 'user', resolution: window._glCamSettings?.resolution || '720' });
const hasVideo = tempStream.getVideoTracks().length > 0;
const hasVideo = tempStream.getVideoTracks().length > 0;
const hasAudio = tempStream.getAudioTracks().length > 0;
const hasAudio = tempStream.getAudioTracks().length > 0;
tempStream.getTracks().forEach(t => t.stop());
tempStream.getTracks().forEach(t => t.stop());
if (mode !== 'mic_only' && !hasVideo || mode !== 'camera_only' && !hasAudio) throw new DOMException('The requested device was not available', 'NotFoundError');
if (mode !== 'mic_only' && !hasVideo || mode !== 'camera_only' && !hasAudio) throw new DOMException('The requested device was not available', 'NotFoundError');
if (dbg) dbg.textContent = 'Enumerating devices...';
if (dbg) dbg.textContent = 'Enumerating devices...';
- const devices = await core.devices();
+ const devices = await OVCapture.devices();
_populateCreateDeviceDropdowns(devices);
_populateCreateDeviceDropdowns(devices);
const saved = window._glCamSettings;
const saved = window._glCamSettings;
const camSelect = document.getElementById('bc-create-camera');
const camSelect = document.getElementById('bc-create-camera');
@@ -621,11 +559,11 @@ async function requestMediaPermissions() {
@@ -621,11 +559,11 @@ async function requestMediaPermissions() {
_glPreviewSync();
_glPreviewSync();
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');
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) {
- // The details stay in the console for us; the person reads what happened and what to tap (_glFixFor).
+ // The details stay in the console for us; the person reads what happened and what to tap (OVCapture.explain).
console.warn('[Broadcast] Permission request failed:', err.name, err.message);
console.warn('[Broadcast] Permission request failed:', err.name, err.message);
if (dbg) dbg.textContent = `${err.name || 'Error'}: ${err.message}`;
if (dbg) dbg.textContent = `${err.name || 'Error'}: ${err.message}`;
failed = true;
failed = true;
- _glShowFix(_glFixFor(err, permMode));
+ _glShowFix(OVCapture.explain(err, permMode));
document.getElementById('gl-fix')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById('gl-fix')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} finally {
} finally {
if (btn) {
if (btn) {
@@ -37,7 +37,7 @@
@@ -37,7 +37,7 @@
try { localStorage.setItem(MIRROR_KEY, mirror ? '1' : '0'); } catch { /* a private window keeps the default */ }
try { localStorage.setItem(MIRROR_KEY, mirror ? '1' : '0'); } catch { /* a private window keeps the default */ }
if (window._glCamSettings) {
if (window._glCamSettings) {
window._glCamSettings.mirror = mirror;
window._glCamSettings.mirror = mirror;
- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mirror }) }).catch(() => {});
+ if (window.OVCapture) OVCapture.settings.save({ mirror }); // this device's camera choices (live-capture.js)
}
}
refresh();
refresh();
}
}
@@ -1070,74 +1070,8 @@ function _glCopyLink() {
@@ -1070,74 +1070,8 @@ function _glCopyLink() {
stream starts or Go live is pressed. Turning it off is remembered on this device. ── */
stream starts or Go live is pressed. Turning it off is remembered on this device. ── */
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;
-/**
- * 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) => {
- 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';
- // 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;
-}
+// The camera is Live's own: public/js/live-capture.js (window.OVCapture), every part replaceable. The page loaded
+// OpenVibe.Cam's cam-kit.js for this until 11-12 (the owner: "we don't want to use openvibe.cam for OpenVibe.Live").
function _glOtherWays() {
function _glOtherWays() {
const fold = document.getElementById('gl-how');
const fold = document.getElementById('gl-how');
if (fold) { fold.open = true; fold.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
if (fold) { fold.open = true; fold.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
@@ -1162,8 +1096,8 @@ function _glSheetMirror(wanted) {
@@ -1162,8 +1096,8 @@ function _glSheetMirror(wanted) {
try { localStorage.setItem('ov_bc_mirror_preview', wanted ? '1' : '0'); } catch {}
try { localStorage.setItem('ov_bc_mirror_preview', wanted ? '1' : '0'); } catch {}
const cam = document.getElementById('bc-create-camera');
const cam = document.getElementById('bc-create-camera');
const settings = { facing: cam?.selectedOptions[0]?.dataset.facing || 'user', lens: cam?.value || 'main', mic: document.getElementById('bc-create-audio')?.value || 'default', resolution: document.getElementById('bc-ws-resolution')?.value || '720', mirror: wanted };
const settings = { facing: cam?.selectedOptions[0]?.dataset.facing || 'user', lens: cam?.value || 'main', mic: document.getElementById('bc-create-audio')?.value || 'default', resolution: document.getElementById('bc-ws-resolution')?.value || '720', mirror: wanted };
- window._glCamSettings = settings;
- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});
+ // This device's camera choices are kept on this device (OVCapture's 'store' part): device ids are this browser's own.
+ window._glCamSettings = OVCapture.settings.save(settings);
_glPreviewSync();
_glPreviewSync();
}
}
@@ -1244,8 +1178,7 @@ async function _glPreviewStart(key) {
@@ -1244,8 +1178,7 @@ async function _glPreviewStart(key) {
_glPreviewStop();
_glPreviewStop();
const [mode, cam, mic, resolution] = key.split('|');
const [mode, cam, mic, resolution] = key.split('|');
try {
try {
- const core = await _glCamCore();
- const stream = await core.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });
+ const stream = await OVCapture.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });
// Things moved on while the camera opened: let it go again.
// Things moved on while the camera opened: let it go again.
if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }
if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }
_glPv.stream = stream;
_glPv.stream = stream;
@@ -1268,7 +1201,7 @@ async function _glPreviewStart(key) {
@@ -1268,7 +1201,7 @@ async function _glPreviewStart(key) {
if (box) box.dataset.state = 'on';
if (box) box.dataset.state = 'on';
const note = document.getElementById('gl-pv-note');
const note = document.getElementById('gl-pv-note');
if (note) note.textContent = '';
if (note) note.textContent = '';
- if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPv.stopMeter = core.meter(stream, (level) => {
+ if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPv.stopMeter = OVCapture.meter(stream, (level) => {
const bar = document.getElementById('gl-pv-lvl');
const bar = document.getElementById('gl-pv-lvl');
if (bar) bar.style.transform = `scaleX(${level.toFixed(3)})`;
if (bar) bar.style.transform = `scaleX(${level.toFixed(3)})`;
});
});
@@ -1286,7 +1219,7 @@ async function _glPreviewStart(key) {
@@ -1286,7 +1219,7 @@ async function _glPreviewStart(key) {
function _glPreviewNetwork() {
function _glPreviewNetwork() {
const chip = document.getElementById('gl-pv-net');
const chip = document.getElementById('gl-pv-net');
if (!chip) return;
if (!chip) return;
- const shared = window.OpenVibeCam?.core?.network?.();
+ const shared = window.OVCapture ? OVCapture.network() : '';
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
let text = '', cls = '';
let text = '', cls = '';
if (shared === 'offline' || navigator.onLine === false) { text = 'Offline'; cls = 'is-bad'; }
if (shared === 'offline' || navigator.onLine === false) { text = 'Offline'; cls = 'is-bad'; }
@@ -1322,7 +1255,7 @@ function _glPreviewSync() {
@@ -1322,7 +1255,7 @@ function _glPreviewSync() {
function _glPreviewWatch() {
function _glPreviewWatch() {
_glPreviewSync();
_glPreviewSync();
if (_glPv.timer) return;
if (_glPv.timer) return;
- fetch('/api/cam/settings', { credentials: 'same-origin' }).then((r) => r.ok ? r.json() : null).then((saved) => {
+ Promise.resolve(OVCapture.settings.load()).then((saved) => {
if (!saved) return;
if (!saved) return;
window._glCamSettings = saved;
window._glCamSettings = saved;
const cam = document.getElementById('bc-create-camera');
const cam = document.getElementById('bc-create-camera');
@@ -1346,8 +1279,7 @@ function _glPreviewWatch() {
@@ -1346,8 +1279,7 @@ function _glPreviewWatch() {
resolution: document.getElementById('bc-ws-resolution')?.value || '720',
resolution: document.getElementById('bc-ws-resolution')?.value || '720',
mirror: (() => { try { return localStorage.getItem('ov_bc_mirror_preview') === '1'; } catch { return true; } })(),
mirror: (() => { try { return localStorage.getItem('ov_bc_mirror_preview') === '1'; } catch { return true; } })(),
};
};
- window._glCamSettings = settings;
- fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});
+ window._glCamSettings = OVCapture.settings.save(settings);
_glPreviewSync();
_glPreviewSync();
});
});
_glPv.timer = setInterval(() => {
_glPv.timer = setInterval(() => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,253 @@
@@ -0,0 +1,253 @@
+/*
+ * Live's capture system: window.OVCapture. Everything the go-live page does with a camera and a microphone, built from
+ * parts that can each be replaced, and nothing that talks to another site.
+ *
+ * The owner, 2026-11-12: "we don't want to use openvibe.cam for OpenVibe.Live, we already had our own custom system for
+ * live stream … so long as we can completely customize it and it's implemented in a highly over engineered modular
+ * editable way." Until then the page loaded OpenVibe.Cam's cam-kit.js for this, and from 11-09 to 11-12 nobody could
+ * turn a camera on when that load was refused.
+ *
+ * Using it:
+ * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. Runs the strategies in order, each with
+ * its own time limit; the first that opens wins.
+ * OVCapture.devices() → the browser's cameras and microphones.
+ * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.
+ * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).
+ * OVCapture.stop(stream)
+ * OVCapture.explain(error, mode) → { title, steps }: what happened and what to tap.
+ * OVCapture.settings.load() / .save(partial) → this device's choices: facing, lens, mic, resolution,
+ * mirror (the 'store' part; this browser by default).
+ *
+ * Changing it:
+ * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:
+ * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));
+ * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, enumerate, meter, network,
+ * store, explain, timeoutMs.
+ * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),
+ * .remove(name), .replace(name, strategy). A strategy is { name, open(options, capture) }.
+ * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·
+ * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings }.
+ * Returns off().
+ * Every name and every default is in this file; nothing is hidden in a library.
+ */
+(function () {
+ 'use strict';
+
+ // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────
+ const parts = {
+ /** What to ask the browser for, from the options (facing, lens, mic, resolution, mode). */
+ constraints(o = {}) {
+ const height = Number(o.resolution) || 720;
+ const 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 },
+ };
+ const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);
+ return { video, audio };
+ },
+ getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
+ enumerate: () => navigator.mediaDevices.enumerateDevices(),
+ meter(stream, onLevel) {
+ const AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC || !stream || !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(() => {}); };
+ },
+ network() {
+ if (navigator.onLine === false) return 'offline';
+ const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
+ if (!c || !(c.effectiveType || c.downlink)) return '';
+ return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
+ },
+ /** Where this device's camera choices live: this browser, because device ids are this browser's own. */
+ store: {
+ key: 'ov_capture_settings',
+ load() { try { return JSON.parse(localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },
+ save(settings) { try { localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },
+ },
+ explain: null, // set below (explainFailure): the plain words for a failure, by browser
+ timeoutMs: 10000,
+ };
+
+ // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────
+ const listeners = new Map();
+ function on(event, fn) {
+ if (!listeners.has(event)) listeners.set(event, new Set());
+ listeners.get(event).add(fn);
+ return () => listeners.get(event).delete(fn);
+ }
+ function emit(event, detail) {
+ (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });
+ }
+
+ // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────
+ const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });
+ const merge = (...streams) => {
+ const out = new MediaStream();
+ streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));
+ return out;
+ };
+ const defaults = [
+ {
+ name: 'preferred',
+ note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',
+ open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),
+ },
+ {
+ name: 'separately',
+ note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',
+ async open(o, c) {
+ const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');
+ let video = null, audio = null, last = null;
+ if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }
+ if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }
+ if (!video && !audio) throw last || new DOMException('No camera or microphone', 'NotFoundError');
+ return merge(video, audio);
+ },
+ },
+ {
+ name: 'basic',
+ note: 'Any camera and any microphone, nothing asked of them.',
+ open: (o, c) => c.part('getUserMedia')(wants(o)),
+ },
+ ];
+ let order = defaults.slice();
+ const strategies = {
+ list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),
+ add(strategy, at = {}) {
+ if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');
+ order = order.filter((s) => s.name !== strategy.name);
+ const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;
+ if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);
+ },
+ remove(name) { order = order.filter((s) => s.name !== name); },
+ replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },
+ reset() { order = defaults.slice(); },
+ };
+
+ // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.
+ const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };
+ const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);
+ function within(promise, ms, name) {
+ let timer;
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])
+ .finally(() => clearTimeout(timer));
+ }
+
+ async function open(options = {}) {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) throw Object.assign(new Error('This browser has no camera here'), { name: 'NoApi' });
+ const o = { ...capture.settings.load(), ...options };
+ let worst = null;
+ for (const s of order) {
+ emit('opening', { strategy: s.name, options: o });
+ try {
+ const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);
+ const w = wants(o);
+ // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.
+ if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {
+ stream.getTracks().forEach((t) => t.stop());
+ throw new DOMException('The requested device was not available', 'NotFoundError');
+ }
+ emit('opened', { strategy: s.name, stream });
+ return stream;
+ } catch (error) {
+ worst = heavier(worst, error);
+ emit('failed', { strategy: s.name, error });
+ // A refusal is the person's answer: asking again in another way would only ask again.
+ if (error && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError' || error.name === 'SecurityError')) break;
+ }
+ }
+ emit('gave-up', { error: worst });
+ throw worst || new Error('The camera did not open');
+ }
+
+ // ── Plain words for a failure, by browser (the go-live card's fix box). ─────────────────────────────────────────
+ function explainFailure(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.'] };
+ }
+ parts.explain = explainFailure;
+
+ // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────
+ const capture = {
+ version: 1,
+ part(name) {
+ if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);
+ return parts[name];
+ },
+ use(name, impl) {
+ const before = capture.part(name);
+ parts[name] = impl;
+ return before;
+ },
+ parts: () => Object.keys(parts),
+ strategies,
+ on,
+ open,
+ devices: () => parts.enumerate(),
+ meter: (stream, onLevel) => parts.meter(stream, onLevel),
+ network: () => parts.network(),
+ stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },
+ explain: (error, mode) => parts.explain(error, mode),
+ settings: {
+ load: () => ({ facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, ...parts.store.load() }),
+ save(partial) {
+ const next = { ...capture.settings.load(), ...(partial || {}) };
+ parts.store.save(next);
+ emit('settings', { settings: next });
+ return next;
+ },
+ },
+ };
+ window.OVCapture = capture;
+})();
@@ -48,16 +48,4 @@ async function createStill({ token, slot, streamId, caption }) {
@@ -48,16 +48,4 @@ async function createStill({ token, slot, streamId, caption }) {
return data;
return data;
}
}
-async function cameraSettings({ token, method = 'GET', settings }) {
- if (!available()) throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');
- if (!token) throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');
- const res = await fetch(`${CAM_URL}/api/v1/me/camera-settings`, {
- method, headers: { Authorization: `Bearer ${token}`, ...(method === 'PUT' ? { 'Content-Type': 'application/json' } : {}) },
- ...(method === 'PUT' ? { body: JSON.stringify(settings) } : {}), signal: AbortSignal.timeout(TIMEOUT_MS),
- });
- const data = await res.json();
- if (!res.ok) throw new CamApiError(data.detail || 'Could not save camera settings', res.status, data.code);
- return data;
-}
-
-module.exports = { CAM_URL, available, createStill, cameraSettings, CamApiError };
+module.exports = { CAM_URL, available, createStill, CamApiError };
@@ -21,19 +21,8 @@ const router = express.Router();
@@ -21,19 +21,8 @@ const router = express.Router();
const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
const SHARE_TEXT_MAX = 500;
const SHARE_TEXT_MAX = 500;
-// The browser never receives its Network token. Live forwards only the signed-in person's choices to Cam.
-router.get('/settings', requireAuth, async (req, res) => {
- try {
- if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });
- return res.json(await cam.cameraSettings({ token: extractToken(req) }));
- } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }
-});
-router.put('/settings', requireAuth, async (req, res) => {
- try {
- if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });
- return res.json(await cam.cameraSettings({ token: extractToken(req), method: 'PUT', settings: req.body }));
- } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }
-});
+// The go-live camera's own choices (facing, lens, microphone, resolution, mirror) stay on the device that made them:
+// public/js/live-capture.js. They were synced through OpenVibe.Cam until 11-12, when Live's capture became its own.
async function liveStream(req, res) {
async function liveStream(req, res) {
const streamId = req.body && Number(req.body.stream_id);
const streamId = req.body && Number(req.body.stream_id);
@@ -391,11 +391,10 @@ app.use(helmet({
@@ -391,11 +391,10 @@ app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
defaultSrc: ["'self'"],
defaultSrc: ["'self'"],
- scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com", "https://openvibe.cam"],
- // esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream
- // openvibe.cam: the go-live page's camera kit (cam-kit.js, hash-pinned) and the stylesheet it adds. Without
- // it every browser refused the kit and "Turn on camera" said "Could not load camera controls" (11-09 to 11-12).
- styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "https://openvibe.cam"],
+ scriptSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "https://openvibe.network", "https://jsmpeg.com", "https://esm.sh", "https://static.cloudflareinsights.com"],
+ // esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream. No other site's script
+ // runs the camera: the go-live page's capture is Live's own (public/js/live-capture.js, 11-12).
+ styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
// i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them
// i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them
// every queued item renders as a broken image, which is what the media
// every queued item renders as a broken image, which is what the media
@@ -422,7 +422,7 @@ function cspReportOnly(html) {
@@ -422,7 +422,7 @@ function cspReportOnly(html) {
if (/type="application\/(ld\+)?json"/i.test(m[1])) continue;
if (/type="application\/(ld\+)?json"/i.test(m[1])) continue;
hashes.add(`'sha256-${crypto.createHash('sha256').update(m[2], 'utf8').digest('base64')}'`);
hashes.add(`'sha256-${crypto.createHash('sha256').update(m[2], 'utf8').digest('base64')}'`);
}
}
- const scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com https://openvibe.cam";
+ const scriptHosts = "cdnjs.cloudflare.com cdn.jsdelivr.net https://openvibe.network https://esm.sh https://static.cloudflareinsights.com";
const policy = [
const policy = [
// The shared navbar adds inline speculation rules (same-site prefetch); CSP 3 allows them with their own
// The shared navbar adds inline speculation rules (same-site prefetch); CSP 3 allows them with their own
// keyword, not a hash (11-03: every guest's direct /broadcast load reported one).
// keyword, not a hash (11-03: every guest's direct /broadcast load reported one).
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
// Every script a page adds from another host is allowed by Live's Content Security Policy, both the enforced one
// Every script a page adds from another host is allowed by Live's Content Security Policy, both the enforced one
// (server/index.js helmet scriptSrc) and the report-only one (server/web/assets.js cspReportOnly).
// (server/index.js helmet scriptSrc) and the report-only one (server/web/assets.js cspReportOnly).
//
//
-// 11-12: the go-live page loads OpenVibe.Cam's camera kit (public/js/broadcast-workspace.js, cam-kit.js with an
-// integrity hash), but script-src never named https://openvibe.cam, so every browser refused it and "Turn on camera"
-// answered "Could not load camera controls" from 11-09 until this test.
+// 11-12: the go-live page loaded OpenVibe.Cam's camera kit while script-src never named https://openvibe.cam, so every
+// browser refused it and "Turn on camera" answered "Could not load camera controls" from 11-09. (Since 11-12 the
+// camera is Live's own, public/js/live-capture.js, and no page loads a script from openvibe.cam.)
// node test/csp-script-hosts.test.js
// node test/csp-script-hosts.test.js
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -41,12 +41,12 @@ for (const f of files) {
@@ -41,12 +41,12 @@ for (const f of files) {
hosts.set(m[1], path.relative(root, f));
hosts.set(m[1], path.relative(root, f));
}
}
}
}
-assert.ok(hosts.has('https://openvibe.cam'), 'the scan finds the camera kit (or the test no longer reads the page)');
+assert.ok(hosts.has('https://cdn.jsdelivr.net'), 'the scan finds a known third-party script (pastes.js), so it reads the pages');
+assert.ok(!hosts.has('https://openvibe.cam'), 'no page loads a script from OpenVibe.Cam: the camera is Live\'s own');
for (const [host, file] of hosts) {
for (const [host, file] of hosts) {
const bare = host.replace(/^https:\/\//, ''); // a source may be written with or without its scheme
const bare = host.replace(/^https:\/\//, ''); // a source may be written with or without its scheme
assert.ok(scriptSrc.includes(`"${host}"`) || scriptSrc.includes(`"${bare}"`), `${file} loads a script from ${host}: script-src must allow it`);
assert.ok(scriptSrc.includes(`"${host}"`) || scriptSrc.includes(`"${bare}"`), `${file} loads a script from ${host}: script-src must allow it`);
assert.ok(reportOnly.split(' ').some((x) => x === host || x === bare), `${file} loads a script from ${host}: the report-only script-src-elem must allow it`);
assert.ok(reportOnly.split(' ').some((x) => x === host || x === bare), `${file} loads a script from ${host}: the report-only script-src-elem must allow it`);
}
}
-// The camera kit adds its own stylesheet from the same host.
-assert.ok(styleSrc.includes('"https://openvibe.cam"'), 'style-src allows the camera kit\'s stylesheet');
+assert.ok(!scriptSrc.includes('openvibe.cam') && !styleSrc.includes('openvibe.cam'), 'and the policy no longer lets one');
console.log(`csp script hosts: ${hosts.size} third-party script host(s) allowed`);
console.log(`csp script hosts: ${hosts.size} third-party script host(s) allowed`);
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
'use strict';
'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
+ * Turning the camera on to go live from a browser (broadcast-devices.js requestMediaPermissions; live-capture.js, Live's
+ * own capture system and its 'explain' part; broadcast-workspace.js the camera card). 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
* 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:
* 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 camera is Live's own (live-capture.js): no other site's script is loaded to open it;
* - the card says what is about to happen and the three steps;
* - 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;
* - 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.
* - the Go Live button turns the camera on first instead of refusing.
@@ -17,11 +17,12 @@ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
@@ -17,11 +17,12 @@ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
const devices = read('public/js/broadcast-devices.js');
const devices = read('public/js/broadcast-devices.js');
const workspace = read('public/js/broadcast-workspace.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 });
+// The 'explain' part (live-capture.js explainFailure), run under each browser.
+const capture = read('public/js/live-capture.js');
+const start = capture.indexOf(' function explainFailure(');
+const end = capture.indexOf('\n parts.explain = explainFailure;', start);
+assert.ok(start > 0 && end > start, 'explainFailure is there');
+const fixUnder = (ua, touch = 0) => new Function('navigator', `${capture.slice(start, end)}; return explainFailure;`)({ userAgent: ua, maxTouchPoints: touch });
const UA = {
const UA = {
androidChrome: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Mobile Safari/537.36',
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',
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',
@@ -55,7 +56,7 @@ for (const ua of Object.values(UA)) {
@@ -55,7 +56,7 @@ for (const ua of Object.values(UA)) {
}
}
// The failure shows in the card, never as raw text; the button under it says Try again.
// 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(devices.includes('_glShowFix(OVCapture.explain(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(!/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(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');
assert.ok(workspace.includes('<p id="bc-perm-debug" hidden></p>'), 'the debug line is never shown to people');
@@ -65,11 +66,11 @@ assert.ok(workspace.includes('Your browser will ask first: tap Allow.'), 'the ca
@@ -65,11 +66,11 @@ assert.ok(workspace.includes('Your browser will ask first: tap Allow.'), 'the ca
assert.ok(workspace.includes('id="gl-steps"') && workspace.includes('Press Go Live'), 'the three steps');
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');
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}`);
+// The camera is Live's own: no other site's kit, and the page opens it through OVCapture.
+const stripComments = (t) => t.replace(/\/\/[^\n]*/g, '');
+assert.ok(!/openvibe\.cam\/embed|cam-kit\.js|OpenVibeCam/.test(stripComments(workspace)), 'broadcast-workspace.js loads nothing from OpenVibe.Cam');
+assert.ok(!/openvibe\.cam\/embed|cam-kit\.js|OpenVibeCam/.test(stripComments(devices)), 'broadcast-devices.js loads nothing from OpenVibe.Cam');
+assert.ok(devices.includes('await OVCapture.open(') && workspace.includes('await OVCapture.open('), 'both the permission and the preview open through OVCapture');
// Go Live turns the camera on first instead of refusing.
// 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()')));
const go = workspace.slice(workspace.indexOf('async function goLiveFromWorkspace()'), workspace.indexOf('await _wsFlushSave();', workspace.indexOf('async function goLiveFromWorkspace()')));
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Live's own capture system (public/js/live-capture.js, window.OVCapture), run against a fake browser: the strategies in
+ * order and the first that opens wins; a refusal stops at once; a strategy that hangs is timed out; every part can be
+ * replaced (and the replaced one called through); strategies can be added, removed and replaced; this device's settings
+ * go through the 'store' part; and the module talks to no other site.
+ * node test/live-capture.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'live-capture.js'), 'utf8');
+assert.ok(!/https?:\/\//.test(source.replace(/^\s*(\/\/|\*|\/\*).*$/gm, '')), 'the module fetches nothing from anywhere');
+
+class FakeTrack { constructor(kind) { this.kind = kind; this.stopped = false; } stop() { this.stopped = true; } }
+class FakeStream {
+ constructor(tracks = []) { this.tracks = tracks.slice(); }
+ getTracks() { return this.tracks; }
+ getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); }
+ getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); }
+ addTrack(t) { this.tracks.push(t); }
+}
+class FakeDOMException extends Error { constructor(message, name) { super(message); this.name = name; } }
+
+function load(gum) {
+ const store = new Map();
+ const calls = [];
+ const navigator = {
+ userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile Safari/537.36', maxTouchPoints: 5, onLine: true,
+ mediaDevices: { getUserMedia: (c) => { calls.push(c); return gum(c, calls.length); }, enumerateDevices: async () => [{ kind: 'videoinput', deviceId: 'a', label: 'Front' }] },
+ };
+ const window = {};
+ const ctx = vm.createContext({ window, navigator, MediaStream: FakeStream, DOMException: FakeDOMException, setTimeout, clearTimeout, console,
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) }, document: { addEventListener() {} } });
+ ctx.globalThis = ctx;
+ vm.runInContext(source.replace("(function () {", "(function () { const window = globalThis.window;"), ctx);
+ return { capture: window.OVCapture, calls, store };
+}
+// Values made inside the vm come from another realm: compare them as JSON.
+const same = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);
+const both = (c) => new FakeStream([...(c.video ? [new FakeTrack('video')] : []), ...(c.audio ? [new FakeTrack('audio')] : [])]);
+
+(async () => {
+ // The preferred way opens it: one call, with the chosen facing and resolution.
+ let t = load(async (c) => both(c));
+ let s = await t.capture.open({ mode: 'camera', facing: 'environment', resolution: '1080' });
+ assert.strictEqual(s.getVideoTracks().length, 1);
+ assert.strictEqual(t.calls.length, 1);
+ same(t.calls[0].video.facingMode, { ideal: 'environment' });
+ assert.strictEqual(t.calls[0].video.height.ideal, 1080);
+
+ // Both at once refused by the hardware (an Android phone): the camera, then the microphone, each on its own.
+ t = load(async (c) => { if (c.video && c.audio) throw new FakeDOMException('busy', 'OverconstrainedError'); return both(c); });
+ const seen = [];
+ t.capture.on('failed', (e) => seen.push(`failed:${e.strategy}`));
+ t.capture.on('opened', (e) => seen.push(`opened:${e.strategy}`));
+ s = await t.capture.open({ mode: 'camera' });
+ assert.strictEqual(s.getVideoTracks().length + s.getAudioTracks().length, 2);
+ same(seen, ['failed:preferred', 'opened:separately']);
+
+ // A refusal is the person's answer: no second way of asking.
+ t = load(async () => { throw new FakeDOMException('denied', 'NotAllowedError'); });
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotAllowedError');
+ assert.strictEqual(t.calls.length, 1, 'stopped after the refusal');
+ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'camera').title, /said no/);
+
+ // Several failures: the one that says most about what to do is the one reported.
+ t = load(async (c, n) => { throw new FakeDOMException('x', n === 1 ? 'NotFoundError' : 'NotReadableError'); });
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotReadableError');
+
+ // A strategy that never answers is timed out, and the next one runs.
+ t = load(async (c, n) => (n === 1 ? new Promise(() => {}) : both(c)));
+ t.capture.use('timeoutMs', 30);
+ s = await t.capture.open({ mode: 'camera' });
+ assert.ok(s.getVideoTracks().length, 'opened after the timed-out strategy');
+
+ // Asked for a camera and given none: not opened (and its tracks are stopped).
+ t = load(async () => new FakeStream([new FakeTrack('audio')]));
+ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotFoundError');
+
+ // Every part can be replaced, and the one it replaced called through.
+ t = load(async (c) => both(c));
+ const before = t.capture.use('constraints', (o) => ({ ...before(o), audio: { echoCancellation: false } }));
+ await t.capture.open({ mode: 'camera' });
+ same(t.calls[0].audio, { echoCancellation: false });
+ assert.throws(() => t.capture.part('nope'), /no capture part "nope"/);
+ assert.ok(['constraints', 'getUserMedia', 'enumerate', 'meter', 'network', 'store', 'explain', 'timeoutMs'].every((p) => t.capture.parts().includes(p)));
+
+ // Strategies: listed, added before another, removed, replaced.
+ t = load(async (c) => both(c));
+ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
+ let ran = '';
+ t.capture.strategies.add({ name: 'mine', open: async (o, c) => { ran = 'mine'; return both({ video: true, audio: true }); } }, { before: 'preferred' });
+ same(t.capture.strategies.list().map((x) => x.name), ['mine', 'preferred', 'separately', 'basic']);
+ await t.capture.open({ mode: 'camera' });
+ assert.strictEqual(ran, 'mine');
+ t.capture.strategies.remove('mine');
+ t.capture.strategies.replace('basic', { open: async () => both({ video: true, audio: true }) });
+ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
+ assert.throws(() => t.capture.strategies.add({ name: 'x' }), /a strategy is/);
+
+ // This device's settings, through the 'store' part, with a 'settings' event.
+ t = load(async (c) => both(c));
+ let saved = null;
+ t.capture.on('settings', (e) => { saved = e.settings; });
+ t.capture.settings.save({ mirror: false, resolution: '1080' });
+ assert.strictEqual(t.capture.settings.load().resolution, '1080');
+ assert.strictEqual(saved.mirror, false);
+ assert.ok(t.store.get('ov_capture_settings').includes('1080'), 'kept on this device');
+ const mem = {};
+ t.capture.use('store', { load: () => mem.v || {}, save: (v) => { mem.v = v; } });
+ t.capture.settings.save({ lens: 'back-wide' });
+ assert.strictEqual(mem.v.lens, 'back-wide', 'a replaced store is used');
+
+ // No camera API at all (an in-app browser): a NoApi error the card explains.
+ t = load(async (c) => both(c));
+ delete t.capture; // reload with no mediaDevices
+ const store = new Map();
+ const w = {};
+ const ctx = vm.createContext({ window: w, navigator: { userAgent: 'Instagram 350', onLine: true }, MediaStream: FakeStream, DOMException: FakeDOMException, setTimeout, clearTimeout, console,
+ localStorage: { getItem: (k) => store.get(k) || null, setItem: (k, v) => store.set(k, v) }, document: { addEventListener() {} } });
+ ctx.globalThis = ctx;
+ vm.runInContext(source.replace("(function () {", "(function () { const window = globalThis.window;"), ctx);
+ await assert.rejects(w.OVCapture.open({}), (e) => e.name === 'NoApi');
+ assert.match(w.OVCapture.explain({ name: 'NoApi' }, 'camera').steps.join(' '), /inside another app/);
+
+ console.log('live-capture: strategies in order, refusals stop, time limits, every part and strategy replaceable, settings on this device');
+})().catch((e) => { console.error(e); process.exit(1); });