openvibers/openvibe.live Commits
openvibe-shared v4.217.0; go live on the shared capture core; Front, Back and every lens switch reliably (#851)
Changes
@@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.410.0- openvibe-sdk: v0.152.0--- openvibe-shared: v4.214.0++- openvibe-shared: v4.217.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.410.0","sdk": "openvibe-sdk v0.152.0",-- "shared": "openvibe-shared v4.215.0",++ "shared": "openvibe-shared v4.217.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -26,7 +26,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -3417,9 +3417,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.215.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",-- "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",++ "version": "4.217.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",++ "integrity": "sha512-g/0RZxFLO1PDUL5lErHSkg5CfwOxQGHzzuqYlvJiVDYFtzq1lZOJjTyf8suJu+adkoNxO2xBTLZGprETmzSM1g==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -46,7 +46,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -260,7 +260,7 @@"/shared/kit-web.js","/js/broadcast-state.js","/js/broadcast-media-setup.js",-- "/js/live-capture.js",++ "/shared/ov-capture.js","/js/broadcast-devices.js","/js/call.js","/js/broadcast-workspace.js",@@@ -131,11 +131,16 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);} catch { /* */ }}++ const goLiveSelect = document.getElementById('bc-create-camera');++ const changed = !!goLiveSelect && goLiveSelect.value !== normalized;_setSelectValueIfPresent(document.getElementById('bc-forceCamera'), normalized);-- _setSelectValueIfPresent(document.getElementById('bc-create-camera'), normalized);++ _setSelectValueIfPresent(goLiveSelect, normalized);_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);broadcastState.settings.forceCamera = normalized;_bcRenderCameraChooser();++ // Tell the page, as a person's own pick from the list does: the camera settings (its side, its lens) are saved and++ // the preview switches now, not at the next check (Back used to keep the front camera's side, 11-12).++ if (changed && goLiveSelect.value === normalized) goLiveSelect.dispatchEvent(new Event('change', { bubbles: true }));if (persist) {saveBroadcastSettings();try { localStorage.setItem('bc-last-camera', normalized); } catch {}@@@ -491,6 +496,8 @@ function _getUserMediaWithTimeout(constraints, timeoutMs = 15000) {* On mobile Android, requesting audio+video together can silently fail,* so we try combined first, then individually.*/++// Live's own last step in the camera help: there is always the screen (OVCapture.explain's { instead }).++const GL_SCREEN_INSTEAD = 'Or go live with your screen instead: "Use a screen or an app instead" below.';async function requestMediaPermissions() {console.log('[Broadcast] requestMediaPermissions() called');const permReq = document.getElementById('bc-perm-request');@@@ -511,14 +518,14 @@ 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(OVCapture.explain({ name: 'NoApi' }, permMode));++ _glShowFix(OVCapture.explain({ name: 'NoApi' }, permMode, { instead: GL_SCREEN_INSTEAD }));if (btn) { btn.disabled = false; btn.innerHTML = btnOrigText; }return;}let failed = false;try {-- // Live's own capture system opens it (public/js/live-capture.js: preferred, then separately, then basic, each++ // The shared capture core opens it (openvibe-shared ov-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';if (dbg) dbg.textContent = 'Requesting camera + mic...';@@@ -563,7 +570,7 @@ async function requestMediaPermissions() {console.warn('[Broadcast] Permission request failed:', err.name, err.message);if (dbg) dbg.textContent = `${err.name || 'Error'}: ${err.message}`;failed = true;-- _glShowFix(OVCapture.explain(err, permMode));++ _glShowFix(OVCapture.explain(err, permMode, { instead: GL_SCREEN_INSTEAD }));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;-- if (window.OVCapture) OVCapture.settings.save({ mirror }); // this device's camera choices (live-capture.js)++ if (window.OVCapture) OVCapture.settings.save({ mirror }); // the camera choices (openvibe-shared ov-capture.js)}refresh();}@@@ -1068,10 +1068,12 @@ function _glCopyLink() {network, before going live. One watch, once a second, owns the camera: it opens it only while this page shows abrowser slot that is not live, with permission already given, and lets go when the tab hides, the page changes, astream 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, pending: false };const _GL_PV_OFF = 'ov_gl_preview_off';--// 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").++// The camera is OpenVibe's shared capture core, served by Live itself: /shared/ov-capture.js (window.OVCapture, every++// part replaceable; openvibe-shared 4.216.0). Live and OpenVibe.Cam use the same one, and a signed-in person's camera++// choices (facing, resolution, mirror) follow them through Cam (the owner, 11-12: "OpenVibe.cam is actually fine …++// Harmony"). Until 11-12 the page loaded Cam's kit from openvibe.cam, which Live's content policy refused.function _glOtherWays() {const fold = document.getElementById('gl-how');if (fold) { fold.open = true; fold.scrollIntoView({ behavior: 'smooth', block: 'start' }); }@@@ -1172,13 +1174,54 @@ function _glPreviewStop() {if (note && _glPreviewOff()) note.textContent = 'Preview off on this device.';}++/*++ * A chosen camera is that camera (the owner, 11-12, a phone screenshot: "even though I have back selected, it didn't++ * actually flip the camera … make it work more reliably for all the controls"). The preview asked for the chosen++ * camera "ideally" and for the saved side "ideally" too, and the saved side was still the front one (picking Back set the++ * select without telling the page), so Android settled the two in favour of the front camera. Now: a chosen device is++ * asked for exactly, with no side beside it; if that id has gone stale, the next way asks for its side exactly (then any++ * camera); and the side always comes from the camera chosen, never from an older setting.++ */++function _glCaptureSetup() {++ if (!window.OVCapture || OVCapture.__glExact) return;++ OVCapture.__glExact = true;++ const before = OVCapture.use('constraints', (o = {}) => {++ const c = before(o);++ if (c.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {++ const { facingMode, deviceId, ...rest } = c.video;++ c.video = { ...rest, deviceId: { exact: o.lens } };++ }++ return c;++ });++ OVCapture.strategies.add({++ name: 'side',++ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',++ open: (o, c) => {++ if (!o.facing || o.video === false || o.mode === 'mic_only') throw new DOMException('No side to ask for', 'NotFoundError');++ const v = { facingMode: { exact: o.facing } };++ const h = Number(o.resolution) || 720;++ return c.part('getUserMedia')({ video: { ...v, width: { ideal: Math.round(h * 16 / 9) }, height: { ideal: h } }, audio: o.mode !== 'camera_only' && o.audio !== false });++ },++ }, { before: 'basic' });++}++/** The side of the camera chosen in the go-live select ('user' | 'environment' | null for a USB camera). */++function _glChosenFacing() {++ const opt = document.getElementById('bc-create-camera')?.selectedOptions?.[0];++ return (opt && opt.dataset.facing) || null;++}++async function _glPreviewStart(key) {-- if (_glPv.starting) return;++ // One camera opens at a time; a change made meanwhile is kept and opened right after (it was dropped until the next++ // second's check, so a quick Front → Back → Front could land on the wrong one).++ if (_glPv.starting) { _glPv.pending = true; return; }_glPv.starting = true;++ _glPv.pending = false;_glPreviewStop();++ _glCaptureSetup();const [mode, cam, mic, resolution] = key.split('|');try {-- const stream = await OVCapture.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });++ const facing = _glChosenFacing() || (cam === 'default' ? window._glCamSettings?.facing : null) || undefined;++ const stream = await OVCapture.open({ mode, video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing, 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;@@@ -1212,6 +1255,7 @@ async function _glPreviewStart(key) {: 'The camera did not open. Choose another camera or turn it on again.';} finally {_glPv.starting = false;++ if (_glPv.pending) { _glPv.pending = false; _glPreviewSync(); }}}@@@ -1255,6 +1299,26 @@ function _glPreviewSync() {function _glPreviewWatch() {_glPreviewSync();if (_glPv.timer) return;++ // Signed in: the choices that mean the same anywhere follow the person through OpenVibe.Cam (Live's /api/cam/settings++ // proxy); camera and microphone ids stay on this device.++ if (typeof currentUser !== 'undefined' && currentUser && !window._glCamSync && OVCapture.settings.sync) {++ window._glCamSync = OVCapture.settings.sync({++ load: () => fetch('/api/cam/settings', { credentials: 'include' }).then((r) => (r.ok ? r.json() : null)),++ save: (s) => fetch('/api/cam/settings', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(s) }),++ });++ OVCapture.on('synced', (e) => {++ const saved = e && e.settings;++ if (!saved) return;++ window._glCamSettings = saved;++ const res = document.getElementById('bc-ws-resolution');++ if (res && [...res.options].some((o) => o.value === saved.resolution)) res.value = saved.resolution;++ const sheetRes = document.getElementById('gl-sheet-resolution');++ if (sheetRes) sheetRes.value = saved.resolution;++ const sheetMirror = document.getElementById('gl-sheet-mirror');++ if (sheetMirror) sheetMirror.checked = !!saved.mirror;++ try { localStorage.setItem('ov_bc_mirror_preview', saved.mirror ? '1' : '0'); } catch {}++ });++ }Promise.resolve(OVCapture.settings.load()).then((saved) => {if (!saved) return;window._glCamSettings = saved;@@@ -3576,10 +3576,11 @@ function clearChatEdit({ keepText = false } = {}) {function _saveChatEdit(input, text) {const ed = _chatEditing;-- if (!ed) return;++ if (!ed || ed.saving) return; // one save at a time: the shared composer and Live's own Enter both hear the keyif (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }const body = ed.muted && !text.startsWith('.') ? '.' + text : text;++ ed.saving = true;input.disabled = true;fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) }).then(async (r) => {@@@ -3589,7 +3590,7 @@ function _saveChatEdit(input, text) {clearChatEdit();}).catch((err) => addSystemMessage(err.message))-- .finally(() => { input.disabled = false; input.focus(); });++ .finally(() => { ed.saving = false; input.disabled = false; input.focus(); });}/**ddeleted file mode 100644@@@ -1,253 +0,0 @@--/*-- * 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,4 +48,18 @@ async function createStill({ token, slot, streamId, caption }) {return data;}--module.exports = { CAM_URL, available, createStill, CamApiError };++// The signed-in person's camera choices on OpenVibe.Cam (GET/PUT /api/v1/me/camera-settings): the shared capture core++// syncs the ones that mean the same on any device (facing, resolution, mirror) through Live's /api/cam/settings.++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 };@@@ -21,8 +21,27 @@ const router = express.Router();const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;const SHARE_TEXT_MAX = 500;--// 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.++// The go-live camera's choices that mean the same on any device (facing, resolution, mirror) follow the signed-in person++// through OpenVibe.Cam: the shared capture core (/shared/ov-capture.js settings.sync) reads and writes them here; camera++// and microphone ids stay on the device. The browser never receives its Network token: Live forwards only the choices.++const PORTABLE = ['facing', 'resolution', 'mirror'];++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' });++ const s = await cam.cameraSettings({ token: extractToken(req) });++ return res.json(Object.fromEntries(PORTABLE.filter((k) => s && s[k] !== undefined).map((k) => [k, s[k]])));++ } 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' });++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const settings = Object.fromEntries(PORTABLE.filter((k) => body[k] !== undefined).map((k) => [k, body[k]]));++ if (!Object.keys(settings).length) return res.status(400).json({ error: 'Nothing to save' });++ const s = await cam.cameraSettings({ token: extractToken(req), method: 'PUT', settings });++ return res.json(Object.fromEntries(PORTABLE.filter((k) => s && s[k] !== undefined).map((k) => [k, s[k]])));++ } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }++});async function liveStream(req, res) {const streamId = req.body && Number(req.body.stream_id);@@@ -393,7 +393,7 @@ app.use(helmet({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"],// 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).++ // runs the camera: the go-live page's capture is Live's own copy of the shared core (/shared/ov-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@@@ -54,6 +54,12 @@ const viewerToken = () => VIEWER_TOKEN;};};++ const settingsCalls = [];++ cam.cameraSettings = async ({ token, method = 'GET', settings }) => {++ settingsCalls.push({ token, method, settings });++ return { facing: 'environment', lens: 'cam-on-another-device', mic: 'usb', resolution: '1080', mirror: false, ...(settings || {}) };++ };++const app = express();app.use(express.json());app.use('/api/cam', require('../server/cam/routes'));@@@ -95,6 +101,19 @@ const viewerToken = () => VIEWER_TOKEN;// Bad slot/stream inputs are refused before Cam is asked.assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);++++ // The camera choices that follow the person (the shared capture core's settings.sync): only the portable ones,++ // both ways; device ids never leave the device or come back from another.++ const get = await fetch(`${base}/api/cam/settings`, { headers: { Authorization: `Bearer ${viewerToken()}` } });++ assert.strictEqual(get.status, 200);++ assert.deepStrictEqual(await get.json(), { facing: 'environment', resolution: '1080', mirror: false });++ const put = await fetch(`${base}/api/cam/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${viewerToken()}` }, body: JSON.stringify({ mirror: true, lens: 'my-camera-id', effects: 'warm' }) });++ assert.strictEqual(put.status, 200);++ assert.deepStrictEqual(settingsCalls[settingsCalls.length - 1].settings, { mirror: true }, 'only the portable choices go to Cam');++ assert.strictEqual(settingsCalls[settingsCalls.length - 1].token, viewerToken(), 'as the person');++ const empty = await fetch(`${base}/api/cam/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${viewerToken()}` }, body: JSON.stringify({ lens: 'x' }) });++ assert.strictEqual(empty.status, 400);++ assert.strictEqual((await fetch(`${base}/api/cam/settings`)).status, 401, 'signed out: nothing to sync');} finally {server.close();fs.rmSync(tmp, { recursive: true, force: true });@@@ -32,6 +32,7 @@ assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an edassert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');++assert.ok(/if \(!ed \|\| ed\.saving\) return;/.test(chat) && /ed\.saving = true;/.test(chat), 'one save at a time (Enter reaches both the shared composer and Live\'s keys)');assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * The go-live camera controls do what they say (the owner, 11-12, a phone screenshot: "even though I have back selected,++ * it didn't actually flip the camera … make it work more reliably for all the controls").++ * - A chosen camera is asked for exactly, with no side beside it (Android settled an "ideal" back camera and an++ * "ideal" front side in favour of the front);++ * - when that id has gone stale, the next way asks for the camera's own side exactly, before any camera at all;++ * - the side comes from the camera chosen; picking Front, Back or a lens tells the page (a change event), so the++ * settings and the preview follow at once; a switch made while a camera opens is kept and opened right after.++ * Runs the shared capture core (openvibe-shared ov-capture.js) with the page's _glCaptureSetup against a fake phone.++ * node test/golive-camera-controls.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');++const workspace = read('public/js/broadcast-workspace.js');++const devices = read('public/js/broadcast-devices.js');++const core = fs.readFileSync(require.resolve('openvibe-shared/ov-capture'), 'utf8');++const slice = (src, start, end) => { const i = src.indexOf(start); const j = src.indexOf(end, i); assert.ok(i >= 0 && j > i, `found ${start}`); return src.slice(i, j); };++const setup = slice(workspace, 'function _glCaptureSetup()', '/** The side of the camera chosen');++++class Track { constructor(kind, facing) { this.kind = kind; this.facing = facing; } stop() {} }++class Stream { constructor(t) { this.t = t; } getTracks() { return this.t; } getVideoTracks() { return this.t.filter((x) => x.kind === 'video'); } getAudioTracks() { return this.t.filter((x) => x.kind === 'audio'); } addTrack(x) { this.t.push(x); } }++class DOMEx extends Error { constructor(m, n) { super(m); this.name = n; } }++++// A phone with a front camera (id f1) and a back camera (id b1). It honours exact ids, and when asked for a side and a++// device "ideally" at once it settles on the side, as the owner's phone did.++function phone({ staleIds = false } = {}) {++ const calls = [];++ const gum = async (c) => {++ calls.push(JSON.parse(JSON.stringify(c)));++ const v = c.video;++ let facing = null;++ if (v) {++ if (v.deviceId && v.deviceId.exact) { if (staleIds) throw new DOMEx('no such device', 'OverconstrainedError'); facing = v.deviceId.exact === 'b1' ? 'environment' : 'user'; }++ else if (v.facingMode && v.facingMode.exact) facing = v.facingMode.exact;++ else if (v.facingMode && v.facingMode.ideal) facing = v.facingMode.ideal;++ else facing = 'user';++ }++ return new Stream([...(v ? [new Track('video', facing)] : []), ...(c.audio ? [new Track('audio')] : [])]);++ };++ const ctx = vm.createContext({ navigator: { userAgent: 'Android Chrome', mediaDevices: { getUserMedia: gum, enumerateDevices: async () => [] } }, MediaStream: Stream, DOMException: DOMEx,++ setTimeout, clearTimeout, console, localStorage: { getItem: () => null, setItem() {} }, document: { addEventListener() {} } });++ ctx.window = ctx;++ vm.runInContext(core, ctx);++ vm.runInContext(setup, ctx);++ ctx._glCaptureSetup();++ return { ctx, calls };++}++++(async () => {++ // Back chosen while the saved side still says front: the back camera opens, asked for exactly.++ let t = phone();++ let s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'environment', resolution: '720' });++ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'Back is the back camera');++ assert.deepStrictEqual(t.calls[0].video.deviceId, { exact: 'b1' });++ assert.ok(!('facingMode' in t.calls[0].video), 'no side asked beside a chosen camera');++ // Even when a stale side is passed with it, the chosen camera wins.++ t = phone();++ s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'user', resolution: '720' });++ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'the chosen camera, whatever side was saved');++++ // The id went stale (a new browser profile): its side, exactly, before any camera.++ t = phone({ staleIds: true });++ s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'environment', resolution: '1080' });++ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'still the back camera');++ assert.deepStrictEqual(t.calls[t.calls.length - 1].video.facingMode, { exact: 'environment' });++ assert.strictEqual(JSON.stringify(t.ctx.OVCapture.strategies.list().map((x) => x.name)), JSON.stringify(['preferred', 'separately', 'side', 'basic']));++++ // Set up once, however many times the preview starts.++ t.ctx._glCaptureSetup();++ assert.strictEqual(JSON.stringify(t.ctx.OVCapture.strategies.list().map((x) => x.name)), JSON.stringify(['preferred', 'separately', 'side', 'basic']));++++ // The page: the side comes from the chosen camera; a pick tells the page; a switch while one opens is kept.++ assert.ok(/const facing = _glChosenFacing\(\) \|\|/.test(workspace), 'the side comes from the camera chosen');++ assert.ok(!/facing: window\._glCamSettings\?\.facing \|\| 'user', resolution \}\)/.test(workspace), 'never the saved side over the chosen camera');++ assert.ok(/if \(_glPv\.starting\) \{ _glPv\.pending = true; return; \}/.test(workspace) && /if \(_glPv\.pending\) \{ _glPv\.pending = false; _glPreviewSync\(\); \}/.test(workspace), 'a switch made meanwhile opens right after');++ assert.ok(/goLiveSelect\.dispatchEvent\(new Event\('change', \{ bubbles: true \}\)\)/.test(devices), 'Front, Back and a lens tell the page like a pick from the list');++ console.log('golive-camera-controls: the chosen camera exactly, its side when the id is stale, the side from the camera, every switch followed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -17,12 +17,18 @@ 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');--// 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 });++// The 'explain' part of the shared capture core (openvibe-shared ov-capture.js, served at /shared/ov-capture.js), run++// under each browser, with Live's own last step (the screen).++const vm = require('vm');++const capture = fs.readFileSync(require.resolve('openvibe-shared/ov-capture'), 'utf8');++const INSTEAD = devices.match(/const GL_SCREEN_INSTEAD = '([^']+)';/);++assert.ok(INSTEAD, "Live's last step is one constant");++const fixUnder = (ua, touch = 0) => {++ const ctx = vm.createContext({ navigator: { userAgent: ua, maxTouchPoints: touch }, console });++ ctx.window = ctx;++ vm.runInContext(capture, ctx);++ return (err, mode) => JSON.parse(JSON.stringify(ctx.OVCapture.explain(err, mode, { instead: INSTEAD[1] })));++};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',@@@ -56,7 +62,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(OVCapture.explain(err, permMode))'), 'a failure fills the card\'s fix box');++assert.ok(devices.includes('_glShowFix(OVCapture.explain(err, permMode, { instead: GL_SCREEN_INSTEAD }))'), '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');@@@ -66,7 +72,9 @@ 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 is Live's own: no other site's kit, and the page opens it through OVCapture.++// The camera is the shared core, served by Live itself: no other site's kit, and the page opens it through OVCapture.++const features = read('public/features.json');++assert.ok(features.includes('"/shared/ov-capture.js"') && !features.includes('live-capture.js'), 'the go-live feature loads Live\'s own copy of the shared core');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');ddeleted file mode 100644@@@ -1,130 +0,0 @@--'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); });
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.410.0
- openvibe-contracts: v0.410.0
- openvibe-sdk: v0.152.0
- openvibe-sdk: v0.152.0
-- openvibe-shared: v4.214.0
+- openvibe-shared: v4.217.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.410.0",
"contracts": "openvibe-contracts v0.410.0",
"sdk": "openvibe-sdk v0.152.0",
"sdk": "openvibe-sdk v0.152.0",
- "shared": "openvibe-shared v4.215.0",
+ "shared": "openvibe-shared v4.217.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3417,9 +3417,9 @@
@@ -3417,9 +3417,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.215.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
- "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
+ "version": "4.217.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
+ "integrity": "sha512-g/0RZxFLO1PDUL5lErHSkg5CfwOxQGHzzuqYlvJiVDYFtzq1lZOJjTyf8suJu+adkoNxO2xBTLZGprETmzSM1g==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.217.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -260,7 +260,7 @@
@@ -260,7 +260,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",
+ "/shared/ov-capture.js",
"/js/broadcast-devices.js",
"/js/broadcast-devices.js",
"/js/call.js",
"/js/call.js",
"/js/broadcast-workspace.js",
"/js/broadcast-workspace.js",
@@ -131,11 +131,16 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
@@ -131,11 +131,16 @@ function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);
if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);
} catch { /* */ }
} catch { /* */ }
}
}
+ const goLiveSelect = document.getElementById('bc-create-camera');
+ const changed = !!goLiveSelect && goLiveSelect.value !== normalized;
_setSelectValueIfPresent(document.getElementById('bc-forceCamera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-forceCamera'), normalized);
- _setSelectValueIfPresent(document.getElementById('bc-create-camera'), normalized);
+ _setSelectValueIfPresent(goLiveSelect, normalized);
_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);
broadcastState.settings.forceCamera = normalized;
broadcastState.settings.forceCamera = normalized;
_bcRenderCameraChooser();
_bcRenderCameraChooser();
+ // Tell the page, as a person's own pick from the list does: the camera settings (its side, its lens) are saved and
+ // the preview switches now, not at the next check (Back used to keep the front camera's side, 11-12).
+ if (changed && goLiveSelect.value === normalized) goLiveSelect.dispatchEvent(new Event('change', { bubbles: true }));
if (persist) {
if (persist) {
saveBroadcastSettings();
saveBroadcastSettings();
try { localStorage.setItem('bc-last-camera', normalized); } catch {}
try { localStorage.setItem('bc-last-camera', normalized); } catch {}
@@ -491,6 +496,8 @@ function _getUserMediaWithTimeout(constraints, timeoutMs = 15000) {
@@ -491,6 +496,8 @@ function _getUserMediaWithTimeout(constraints, timeoutMs = 15000) {
* On mobile Android, requesting audio+video together can silently fail,
* On mobile Android, requesting audio+video together can silently fail,
* so we try combined first, then individually.
* so we try combined first, then individually.
*/
*/
+// Live's own last step in the camera help: there is always the screen (OVCapture.explain's { instead }).
+const GL_SCREEN_INSTEAD = 'Or go live with your screen instead: "Use a screen or an app instead" below.';
async function requestMediaPermissions() {
async function requestMediaPermissions() {
console.log('[Broadcast] requestMediaPermissions() called');
console.log('[Broadcast] requestMediaPermissions() called');
const permReq = document.getElementById('bc-perm-request');
const permReq = document.getElementById('bc-perm-request');
@@ -511,14 +518,14 @@ async function requestMediaPermissions() {
@@ -511,14 +518,14 @@ 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(OVCapture.explain({ name: 'NoApi' }, permMode));
+ _glShowFix(OVCapture.explain({ name: 'NoApi' }, permMode, { instead: GL_SCREEN_INSTEAD }));
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 {
- // Live's own capture system opens it (public/js/live-capture.js: preferred, then separately, then basic, each
+ // The shared capture core opens it (openvibe-shared ov-capture.js: preferred, then separately, then basic, each
// timed); a refusal stops at once, because asking another way would only ask again.
// 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';
if (dbg) dbg.textContent = 'Requesting camera + mic...';
if (dbg) dbg.textContent = 'Requesting camera + mic...';
@@ -563,7 +570,7 @@ async function requestMediaPermissions() {
@@ -563,7 +570,7 @@ async function requestMediaPermissions() {
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(OVCapture.explain(err, permMode));
+ _glShowFix(OVCapture.explain(err, permMode, { instead: GL_SCREEN_INSTEAD }));
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;
- if (window.OVCapture) OVCapture.settings.save({ mirror }); // this device's camera choices (live-capture.js)
+ if (window.OVCapture) OVCapture.settings.save({ mirror }); // the camera choices (openvibe-shared ov-capture.js)
}
}
refresh();
refresh();
}
}
@@ -1068,10 +1068,12 @@ function _glCopyLink() {
@@ -1068,10 +1068,12 @@ function _glCopyLink() {
network, before going live. One watch, once a second, owns the camera: it opens it only while this page shows a
network, before going live. One watch, once a second, owns the camera: it opens it only while this page shows a
browser slot that is not live, with permission already given, and lets go when the tab hides, the page changes, a
browser slot that is not live, with permission already given, and lets go when the tab hides, the page changes, a
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, pending: false };
const _GL_PV_OFF = 'ov_gl_preview_off';
const _GL_PV_OFF = 'ov_gl_preview_off';
-// 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").
+// The camera is OpenVibe's shared capture core, served by Live itself: /shared/ov-capture.js (window.OVCapture, every
+// part replaceable; openvibe-shared 4.216.0). Live and OpenVibe.Cam use the same one, and a signed-in person's camera
+// choices (facing, resolution, mirror) follow them through Cam (the owner, 11-12: "OpenVibe.cam is actually fine …
+// Harmony"). Until 11-12 the page loaded Cam's kit from openvibe.cam, which Live's content policy refused.
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' }); }
@@ -1172,13 +1174,54 @@ function _glPreviewStop() {
@@ -1172,13 +1174,54 @@ function _glPreviewStop() {
if (note && _glPreviewOff()) note.textContent = 'Preview off on this device.';
if (note && _glPreviewOff()) note.textContent = 'Preview off on this device.';
}
}
+/*
+ * A chosen camera is that camera (the owner, 11-12, a phone screenshot: "even though I have back selected, it didn't
+ * actually flip the camera … make it work more reliably for all the controls"). The preview asked for the chosen
+ * camera "ideally" and for the saved side "ideally" too, and the saved side was still the front one (picking Back set the
+ * select without telling the page), so Android settled the two in favour of the front camera. Now: a chosen device is
+ * asked for exactly, with no side beside it; if that id has gone stale, the next way asks for its side exactly (then any
+ * camera); and the side always comes from the camera chosen, never from an older setting.
+ */
+function _glCaptureSetup() {
+ if (!window.OVCapture || OVCapture.__glExact) return;
+ OVCapture.__glExact = true;
+ const before = OVCapture.use('constraints', (o = {}) => {
+ const c = before(o);
+ if (c.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {
+ const { facingMode, deviceId, ...rest } = c.video;
+ c.video = { ...rest, deviceId: { exact: o.lens } };
+ }
+ return c;
+ });
+ OVCapture.strategies.add({
+ name: 'side',
+ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',
+ open: (o, c) => {
+ if (!o.facing || o.video === false || o.mode === 'mic_only') throw new DOMException('No side to ask for', 'NotFoundError');
+ const v = { facingMode: { exact: o.facing } };
+ const h = Number(o.resolution) || 720;
+ return c.part('getUserMedia')({ video: { ...v, width: { ideal: Math.round(h * 16 / 9) }, height: { ideal: h } }, audio: o.mode !== 'camera_only' && o.audio !== false });
+ },
+ }, { before: 'basic' });
+}
+/** The side of the camera chosen in the go-live select ('user' | 'environment' | null for a USB camera). */
+function _glChosenFacing() {
+ const opt = document.getElementById('bc-create-camera')?.selectedOptions?.[0];
+ return (opt && opt.dataset.facing) || null;
+}
+
async function _glPreviewStart(key) {
async function _glPreviewStart(key) {
- if (_glPv.starting) return;
+ // One camera opens at a time; a change made meanwhile is kept and opened right after (it was dropped until the next
+ // second's check, so a quick Front → Back → Front could land on the wrong one).
+ if (_glPv.starting) { _glPv.pending = true; return; }
_glPv.starting = true;
_glPv.starting = true;
+ _glPv.pending = false;
_glPreviewStop();
_glPreviewStop();
+ _glCaptureSetup();
const [mode, cam, mic, resolution] = key.split('|');
const [mode, cam, mic, resolution] = key.split('|');
try {
try {
- const stream = await OVCapture.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });
+ const facing = _glChosenFacing() || (cam === 'default' ? window._glCamSettings?.facing : null) || undefined;
+ const stream = await OVCapture.open({ mode, video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing, 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;
@@ -1212,6 +1255,7 @@ async function _glPreviewStart(key) {
@@ -1212,6 +1255,7 @@ async function _glPreviewStart(key) {
: 'The camera did not open. Choose another camera or turn it on again.';
: 'The camera did not open. Choose another camera or turn it on again.';
} finally {
} finally {
_glPv.starting = false;
_glPv.starting = false;
+ if (_glPv.pending) { _glPv.pending = false; _glPreviewSync(); }
}
}
}
}
@@ -1255,6 +1299,26 @@ function _glPreviewSync() {
@@ -1255,6 +1299,26 @@ function _glPreviewSync() {
function _glPreviewWatch() {
function _glPreviewWatch() {
_glPreviewSync();
_glPreviewSync();
if (_glPv.timer) return;
if (_glPv.timer) return;
+ // Signed in: the choices that mean the same anywhere follow the person through OpenVibe.Cam (Live's /api/cam/settings
+ // proxy); camera and microphone ids stay on this device.
+ if (typeof currentUser !== 'undefined' && currentUser && !window._glCamSync && OVCapture.settings.sync) {
+ window._glCamSync = OVCapture.settings.sync({
+ load: () => fetch('/api/cam/settings', { credentials: 'include' }).then((r) => (r.ok ? r.json() : null)),
+ save: (s) => fetch('/api/cam/settings', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(s) }),
+ });
+ OVCapture.on('synced', (e) => {
+ const saved = e && e.settings;
+ if (!saved) return;
+ window._glCamSettings = saved;
+ const res = document.getElementById('bc-ws-resolution');
+ if (res && [...res.options].some((o) => o.value === saved.resolution)) res.value = saved.resolution;
+ const sheetRes = document.getElementById('gl-sheet-resolution');
+ if (sheetRes) sheetRes.value = saved.resolution;
+ const sheetMirror = document.getElementById('gl-sheet-mirror');
+ if (sheetMirror) sheetMirror.checked = !!saved.mirror;
+ try { localStorage.setItem('ov_bc_mirror_preview', saved.mirror ? '1' : '0'); } catch {}
+ });
+ }
Promise.resolve(OVCapture.settings.load()).then((saved) => {
Promise.resolve(OVCapture.settings.load()).then((saved) => {
if (!saved) return;
if (!saved) return;
window._glCamSettings = saved;
window._glCamSettings = saved;
@@ -3576,10 +3576,11 @@ function clearChatEdit({ keepText = false } = {}) {
@@ -3576,10 +3576,11 @@ function clearChatEdit({ keepText = false } = {}) {
function _saveChatEdit(input, text) {
function _saveChatEdit(input, text) {
const ed = _chatEditing;
const ed = _chatEditing;
- if (!ed) return;
+ if (!ed || ed.saving) return; // one save at a time: the shared composer and Live's own Enter both hear the key
if (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }
if (!text) { addSystemMessage('A message can\'t be empty. To take it back, delete it from its menu.'); return; }
if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }
if (messageHitsSlurPolicy(text)) { showSlurNudgeModal(); return; }
const body = ed.muted && !text.startsWith('.') ? '.' + text : text;
const body = ed.muted && !text.startsWith('.') ? '.' + text : text;
+ ed.saving = true;
input.disabled = true;
input.disabled = true;
fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
fetch(`/api/chat/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: body }) })
.then(async (r) => {
.then(async (r) => {
@@ -3589,7 +3590,7 @@ function _saveChatEdit(input, text) {
@@ -3589,7 +3590,7 @@ function _saveChatEdit(input, text) {
clearChatEdit();
clearChatEdit();
})
})
.catch((err) => addSystemMessage(err.message))
.catch((err) => addSystemMessage(err.message))
- .finally(() => { input.disabled = false; input.focus(); });
+ .finally(() => { ed.saving = false; input.disabled = false; input.focus(); });
}
}
/**
/**
deleted file mode 100644
deleted file mode 100644
@@ -1,253 +0,0 @@
@@ -1,253 +0,0 @@
-/*
- * 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,4 +48,18 @@ async function createStill({ token, slot, streamId, caption }) {
@@ -48,4 +48,18 @@ async function createStill({ token, slot, streamId, caption }) {
return data;
return data;
}
}
-module.exports = { CAM_URL, available, createStill, CamApiError };
+// The signed-in person's camera choices on OpenVibe.Cam (GET/PUT /api/v1/me/camera-settings): the shared capture core
+// syncs the ones that mean the same on any device (facing, resolution, mirror) through Live's /api/cam/settings.
+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 };
@@ -21,8 +21,27 @@ const router = express.Router();
@@ -21,8 +21,27 @@ 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 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.
+// The go-live camera's choices that mean the same on any device (facing, resolution, mirror) follow the signed-in person
+// through OpenVibe.Cam: the shared capture core (/shared/ov-capture.js settings.sync) reads and writes them here; camera
+// and microphone ids stay on the device. The browser never receives its Network token: Live forwards only the choices.
+const PORTABLE = ['facing', 'resolution', 'mirror'];
+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' });
+ const s = await cam.cameraSettings({ token: extractToken(req) });
+ return res.json(Object.fromEntries(PORTABLE.filter((k) => s && s[k] !== undefined).map((k) => [k, s[k]])));
+ } 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' });
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const settings = Object.fromEntries(PORTABLE.filter((k) => body[k] !== undefined).map((k) => [k, body[k]]));
+ if (!Object.keys(settings).length) return res.status(400).json({ error: 'Nothing to save' });
+ const s = await cam.cameraSettings({ token: extractToken(req), method: 'PUT', settings });
+ return res.json(Object.fromEntries(PORTABLE.filter((k) => s && s[k] !== undefined).map((k) => [k, s[k]])));
+ } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }
+});
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);
@@ -393,7 +393,7 @@ app.use(helmet({
@@ -393,7 +393,7 @@ app.use(helmet({
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"],
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
// 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).
+ // runs the camera: the go-live page's capture is Live's own copy of the shared core (/shared/ov-capture.js, 11-12).
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
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
@@ -54,6 +54,12 @@ const viewerToken = () => VIEWER_TOKEN;
@@ -54,6 +54,12 @@ const viewerToken = () => VIEWER_TOKEN;
};
};
};
};
+ const settingsCalls = [];
+ cam.cameraSettings = async ({ token, method = 'GET', settings }) => {
+ settingsCalls.push({ token, method, settings });
+ return { facing: 'environment', lens: 'cam-on-another-device', mic: 'usb', resolution: '1080', mirror: false, ...(settings || {}) };
+ };
+
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/cam', require('../server/cam/routes'));
app.use('/api/cam', require('../server/cam/routes'));
@@ -95,6 +101,19 @@ const viewerToken = () => VIEWER_TOKEN;
@@ -95,6 +101,19 @@ const viewerToken = () => VIEWER_TOKEN;
// Bad slot/stream inputs are refused before Cam is asked.
// Bad slot/stream inputs are refused before Cam is asked.
assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);
assert.strictEqual((await post('/stills', { slot: '', stream_id: streamId })).status, 400);
assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);
assert.strictEqual((await post('/stills', { slot: 'whip', stream_id: 'nope' })).status, 400);
+
+ // The camera choices that follow the person (the shared capture core's settings.sync): only the portable ones,
+ // both ways; device ids never leave the device or come back from another.
+ const get = await fetch(`${base}/api/cam/settings`, { headers: { Authorization: `Bearer ${viewerToken()}` } });
+ assert.strictEqual(get.status, 200);
+ assert.deepStrictEqual(await get.json(), { facing: 'environment', resolution: '1080', mirror: false });
+ const put = await fetch(`${base}/api/cam/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${viewerToken()}` }, body: JSON.stringify({ mirror: true, lens: 'my-camera-id', effects: 'warm' }) });
+ assert.strictEqual(put.status, 200);
+ assert.deepStrictEqual(settingsCalls[settingsCalls.length - 1].settings, { mirror: true }, 'only the portable choices go to Cam');
+ assert.strictEqual(settingsCalls[settingsCalls.length - 1].token, viewerToken(), 'as the person');
+ const empty = await fetch(`${base}/api/cam/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${viewerToken()}` }, body: JSON.stringify({ lens: 'x' }) });
+ assert.strictEqual(empty.status, 400);
+ assert.strictEqual((await fetch(`${base}/api/cam/settings`)).status, 401, 'signed out: nothing to sync');
} finally {
} finally {
server.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -32,6 +32,7 @@ assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an ed
@@ -32,6 +32,7 @@ assert.ok(!/window\.prompt\('Edit message'/.test(chat), 'no prompt box for an ed
assert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));
assert.ok(/function setChatEdit\(row\)/.test(chat) && /function clearChatEdit\(/.test(chat) && /function _saveChatEdit\(/.test(chat));
assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');
assert.ok(/if \(_chatEditing\) \{ _saveChatEdit\(input, text\); return; \}/.test(chat), 'Enter in the box saves the edit');
assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');
assert.ok(/e\.key === 'Escape' && _chatEditing/.test(chat), 'Escape stops editing');
+assert.ok(/if \(!ed \|\| ed\.saving\) return;/.test(chat) && /ed\.saving = true;/.test(chat), 'one save at a time (Enter reaches both the shared composer and Live\'s keys)');
assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');
assert.ok(/Editing your message/.test(chat), 'the bar says what is happening');
assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');
assert.ok(/'\.chat-reply-bar:not\(\.chat-edit-bar\)'/.test(chat), 'replying and editing never reuse each other\'s bar');
assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');
assert.ok(/ed\.muted && !text\.startsWith\('\.'\) \? '\.' \+ text : text/.test(chat), 'a message sent without TTS stays without TTS when edited');
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * The go-live camera controls do what they say (the owner, 11-12, a phone screenshot: "even though I have back selected,
+ * it didn't actually flip the camera … make it work more reliably for all the controls").
+ * - A chosen camera is asked for exactly, with no side beside it (Android settled an "ideal" back camera and an
+ * "ideal" front side in favour of the front);
+ * - when that id has gone stale, the next way asks for the camera's own side exactly, before any camera at all;
+ * - the side comes from the camera chosen; picking Front, Back or a lens tells the page (a change event), so the
+ * settings and the preview follow at once; a switch made while a camera opens is kept and opened right after.
+ * Runs the shared capture core (openvibe-shared ov-capture.js) with the page's _glCaptureSetup against a fake phone.
+ * node test/golive-camera-controls.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const workspace = read('public/js/broadcast-workspace.js');
+const devices = read('public/js/broadcast-devices.js');
+const core = fs.readFileSync(require.resolve('openvibe-shared/ov-capture'), 'utf8');
+const slice = (src, start, end) => { const i = src.indexOf(start); const j = src.indexOf(end, i); assert.ok(i >= 0 && j > i, `found ${start}`); return src.slice(i, j); };
+const setup = slice(workspace, 'function _glCaptureSetup()', '/** The side of the camera chosen');
+
+class Track { constructor(kind, facing) { this.kind = kind; this.facing = facing; } stop() {} }
+class Stream { constructor(t) { this.t = t; } getTracks() { return this.t; } getVideoTracks() { return this.t.filter((x) => x.kind === 'video'); } getAudioTracks() { return this.t.filter((x) => x.kind === 'audio'); } addTrack(x) { this.t.push(x); } }
+class DOMEx extends Error { constructor(m, n) { super(m); this.name = n; } }
+
+// A phone with a front camera (id f1) and a back camera (id b1). It honours exact ids, and when asked for a side and a
+// device "ideally" at once it settles on the side, as the owner's phone did.
+function phone({ staleIds = false } = {}) {
+ const calls = [];
+ const gum = async (c) => {
+ calls.push(JSON.parse(JSON.stringify(c)));
+ const v = c.video;
+ let facing = null;
+ if (v) {
+ if (v.deviceId && v.deviceId.exact) { if (staleIds) throw new DOMEx('no such device', 'OverconstrainedError'); facing = v.deviceId.exact === 'b1' ? 'environment' : 'user'; }
+ else if (v.facingMode && v.facingMode.exact) facing = v.facingMode.exact;
+ else if (v.facingMode && v.facingMode.ideal) facing = v.facingMode.ideal;
+ else facing = 'user';
+ }
+ return new Stream([...(v ? [new Track('video', facing)] : []), ...(c.audio ? [new Track('audio')] : [])]);
+ };
+ const ctx = vm.createContext({ navigator: { userAgent: 'Android Chrome', mediaDevices: { getUserMedia: gum, enumerateDevices: async () => [] } }, MediaStream: Stream, DOMException: DOMEx,
+ setTimeout, clearTimeout, console, localStorage: { getItem: () => null, setItem() {} }, document: { addEventListener() {} } });
+ ctx.window = ctx;
+ vm.runInContext(core, ctx);
+ vm.runInContext(setup, ctx);
+ ctx._glCaptureSetup();
+ return { ctx, calls };
+}
+
+(async () => {
+ // Back chosen while the saved side still says front: the back camera opens, asked for exactly.
+ let t = phone();
+ let s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'environment', resolution: '720' });
+ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'Back is the back camera');
+ assert.deepStrictEqual(t.calls[0].video.deviceId, { exact: 'b1' });
+ assert.ok(!('facingMode' in t.calls[0].video), 'no side asked beside a chosen camera');
+ // Even when a stale side is passed with it, the chosen camera wins.
+ t = phone();
+ s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'user', resolution: '720' });
+ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'the chosen camera, whatever side was saved');
+
+ // The id went stale (a new browser profile): its side, exactly, before any camera.
+ t = phone({ staleIds: true });
+ s = await t.ctx.OVCapture.open({ mode: 'camera', lens: 'b1', facing: 'environment', resolution: '1080' });
+ assert.strictEqual(s.getVideoTracks()[0].facing, 'environment', 'still the back camera');
+ assert.deepStrictEqual(t.calls[t.calls.length - 1].video.facingMode, { exact: 'environment' });
+ assert.strictEqual(JSON.stringify(t.ctx.OVCapture.strategies.list().map((x) => x.name)), JSON.stringify(['preferred', 'separately', 'side', 'basic']));
+
+ // Set up once, however many times the preview starts.
+ t.ctx._glCaptureSetup();
+ assert.strictEqual(JSON.stringify(t.ctx.OVCapture.strategies.list().map((x) => x.name)), JSON.stringify(['preferred', 'separately', 'side', 'basic']));
+
+ // The page: the side comes from the chosen camera; a pick tells the page; a switch while one opens is kept.
+ assert.ok(/const facing = _glChosenFacing\(\) \|\|/.test(workspace), 'the side comes from the camera chosen');
+ assert.ok(!/facing: window\._glCamSettings\?\.facing \|\| 'user', resolution \}\)/.test(workspace), 'never the saved side over the chosen camera');
+ assert.ok(/if \(_glPv\.starting\) \{ _glPv\.pending = true; return; \}/.test(workspace) && /if \(_glPv\.pending\) \{ _glPv\.pending = false; _glPreviewSync\(\); \}/.test(workspace), 'a switch made meanwhile opens right after');
+ assert.ok(/goLiveSelect\.dispatchEvent\(new Event\('change', \{ bubbles: true \}\)\)/.test(devices), 'Front, Back and a lens tell the page like a pick from the list');
+ console.log('golive-camera-controls: the chosen camera exactly, its side when the id is stale, the side from the camera, every switch followed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -17,12 +17,18 @@ const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
@@ -17,12 +17,18 @@ 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');
-// 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 });
+// The 'explain' part of the shared capture core (openvibe-shared ov-capture.js, served at /shared/ov-capture.js), run
+// under each browser, with Live's own last step (the screen).
+const vm = require('vm');
+const capture = fs.readFileSync(require.resolve('openvibe-shared/ov-capture'), 'utf8');
+const INSTEAD = devices.match(/const GL_SCREEN_INSTEAD = '([^']+)';/);
+assert.ok(INSTEAD, "Live's last step is one constant");
+const fixUnder = (ua, touch = 0) => {
+ const ctx = vm.createContext({ navigator: { userAgent: ua, maxTouchPoints: touch }, console });
+ ctx.window = ctx;
+ vm.runInContext(capture, ctx);
+ return (err, mode) => JSON.parse(JSON.stringify(ctx.OVCapture.explain(err, mode, { instead: INSTEAD[1] })));
+};
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',
@@ -56,7 +62,7 @@ for (const ua of Object.values(UA)) {
@@ -56,7 +62,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(OVCapture.explain(err, permMode))'), 'a failure fills the card\'s fix box');
+assert.ok(devices.includes('_glShowFix(OVCapture.explain(err, permMode, { instead: GL_SCREEN_INSTEAD }))'), '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');
@@ -66,7 +72,9 @@ assert.ok(workspace.includes('Your browser will ask first: tap Allow.'), 'the ca
@@ -66,7 +72,9 @@ 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 is Live's own: no other site's kit, and the page opens it through OVCapture.
+// The camera is the shared core, served by Live itself: no other site's kit, and the page opens it through OVCapture.
+const features = read('public/features.json');
+assert.ok(features.includes('"/shared/ov-capture.js"') && !features.includes('live-capture.js'), 'the go-live feature loads Live\'s own copy of the shared core');
const stripComments = (t) => t.replace(/\/\/[^\n]*/g, '');
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(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(!/openvibe\.cam\/embed|cam-kit\.js|OpenVibeCam/.test(stripComments(devices)), 'broadcast-devices.js loads nothing from OpenVibe.Cam');
deleted file mode 100644
deleted file mode 100644
@@ -1,130 +0,0 @@
@@ -1,130 +0,0 @@
-'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); });