openvibers/openvibe.live Commits

Go live: Live's own capture system (OVCapture), and nothing from openvibe.cam (#847)

OP OpenVibers committed 1 days ago · 76bd6b2c0e · 12 files, +432 −201

The owner: "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."

public/js/live-capture.js is window.OVCapture: everything the go-live page does
with a camera and a microphone, as parts that can each be replaced, and nothing
that talks to another site.
- open(options) runs the strategies in order (preferred: camera and microphone
  together as chosen; separately: one then the other, for phones that refuse
  both at once; basic: any camera and microphone), each with its own time limit.
  A refusal stops at once; when several fail, the error that says most about
  what to do is the one reported. A stream with less than was asked is not
  "opened".
- use(name, impl) replaces a part and returns the replaced one, so a wrapper can
  call through: constraints, getUserMedia, enumerate, meter, network, store,
  explain, timeoutMs. strategies.add/remove/replace/reset/list. on('opening' |
  'opened' | 'failed' | 'gave-up' | 'settings').
- settings.load/save keep this device's choices (facing, lens, microphone,
  resolution, mirror) on this device: device ids are the browser's own.
- explain(error, mode) is the plain help from #844, now the 'explain' part.

The go-live page (permission, preview, meter, network chip, mirror) uses only
OVCapture. Removed: the cam-kit.js load, openvibe.cam in script-src, style-src
and the report-only policy, and the /api/cam/settings proxy to Cam (no caller
left). The viewers' still button (Cam to Pics) is unchanged.

Tests: live-capture (the module against a fake browser), golive-camera-help,
csp-script-hosts; all go-live, broadcast, CSP and Cam tests pass (36).

Changes

ddiff --git a/public/features.json b/public/features.json
iindex cdae6de2..aca66b05 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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",
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 2068c34d..559bfd75 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.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) {
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex c5b20bce..479b5c87 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -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();
}
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 45efc2c5..af453451 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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(() => {
ddiff --git a/public/js/live-capture.js b/public/js/live-capture.js
nnew file mode 100644
iindex 00000000..95d20c52
---- /dev/null
++++ b/public/js/live-capture.js
@@@ -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;
++})();
ddiff --git a/server/cam/cam-client.js b/server/cam/cam-client.js
iindex f695baec..0229d085 100644
---- a/server/cam/cam-client.js
++++ b/server/cam/cam-client.js
@@@ -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 };
ddiff --git a/server/cam/routes.js b/server/cam/routes.js
iindex 251584f6..077110c4 100644
---- a/server/cam/routes.js
++++ b/server/cam/routes.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 1d87d0d1..6162ee20 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 691456c4..402fca30 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -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).
ddiff --git a/test/csp-script-hosts.test.js b/test/csp-script-hosts.test.js
iindex 53d423df..25becec2 100644
---- a/test/csp-script-hosts.test.js
++++ b/test/csp-script-hosts.test.js
@@@ -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.js
const 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 scheme
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`);
}
--// 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`);
ddiff --git a/test/golive-camera-help.test.js b/test/golive-camera-help.test.js
iindex b72090f6..99ce4155 100644
---- a/test/golive-camera-help.test.js
++++ b/test/golive-camera-help.test.js
@@@ -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 ca
assert.ok(workspace.includes('id="gl-steps"') && workspace.includes('Press Go Live'), 'the three steps');
assert.ok(workspace.includes('id="gl-fix"'), 'the fix box');
--// The camera never waits on openvibe.cam.
--const core = workspace.slice(workspace.indexOf('function _glCamCore()'), workspace.indexOf('function _glOtherWays()'));
--assert.ok(core.includes('resolve(_glLocalCore)') && !core.includes('reject('), 'a kit that cannot load falls back to the built-in core');
--assert.ok(core.includes('setTimeout(() => fallback'), 'and a kit that never answers too');
--for (const m of ['devices', 'constraints', 'open', 'stop', 'network', 'meter']) assert.ok(new RegExp(`\\b${m}: `).test(workspace.slice(workspace.indexOf('const _glLocalCore'), workspace.indexOf('function _glCamCore()'))), `the built-in core has ${m}`);
++// 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()')));
ddiff --git a/test/live-capture.test.js b/test/live-capture.test.js
nnew file mode 100644
iindex 00000000..72a00632
---- /dev/null
++++ b/test/live-capture.test.js
@@@ -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); });