openvibers/openvibe.live Commits

openvibe-shared v4.217.0; go live on the shared capture core; Front, Back and every lens switch reliably (#851)

OP OpenVibers committed 1 days ago · 3a5cc37b96 · 18 files, +248 −415

* openvibe-shared v4.216.0 (was v4.214.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v4.216.0 release.

* Go live: the shared capture core, and camera choices that follow you through Cam

The owner, 11-12: "OpenVibe.cam is actually fine … maybe we should be using that for
live … Harmony … make everything work better."

- The go-live page opens the camera through openvibe-shared ov-capture.js (4.216.0),
  served by Live itself at /shared/ov-capture.js: the same capture core OpenVibe.Cam
  and the composer use, with Live's own last step in its help (go live with your
  screen instead). public/js/live-capture.js, its source, is retired.
- Signed in, the choices that mean the same on any device (facing, resolution,
  mirror) follow you through OpenVibe.Cam: /api/cam/settings forwards only those, as
  you, and camera and microphone ids stay on the device.
- Nothing is loaded from another site, so a content policy or Cam's uptime can never
  stop a camera again.

* Chat: an edit saves once, whichever Enter hears it first

The stream chat's box moves onto the shared composer (Live #850); its Enter and
Live's own both reach sendChat, and while you edit a message that could save it
twice. _saveChatEdit now runs one save at a time.

* Go live: Front, Back and every lens switch the camera, reliably

The owner, 11-12, a phone screenshot: Back selected, the front camera still on
("make it work more reliably for all the controls"). The preview asked for the
chosen camera "ideally" and for the saved side "ideally" too; picking Back set
the select without telling the page, so the saved side stayed front, and Android
settled the two in favour of the front camera.

- A chosen camera is asked for exactly, with no side beside it; if its id has gone
  stale, the next way asks for its side exactly, then any camera.
- The side always comes from the camera chosen.
- Front, Back and a lens tell the page like a pick from the list: the settings save
  and the preview switches at once.
- A switch made while a camera opens is kept and opened right after (it waited for
  the next one-second check).
test/golive-camera-controls.test.js runs the shared capture core against a fake phone.

Changes

ddiff --git a/README.md b/README.md
iindex 55dfb533..52895c59 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 68e88779..8615c568 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 64e77c2d..16a874cb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c648569e..0ce651f8 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/features.json b/public/features.json
iindex da7241ea..3e286583 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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",
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 559bfd75..fa4102c3 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.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) {
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 479b5c87..3fef330e 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -37,7 +37,7 @@
try { localStorage.setItem(MIRROR_KEY, mirror ? '1' : '0'); } catch { /* a private window keeps the default */ }
if (window._glCamSettings) {
window._glCamSettings.mirror = mirror;
-- 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();
}
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex af453451..f872e8e2 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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
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. ── */
--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;
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 9f6b67d3..29a39210 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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 key
if (!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(); });
}
/**
ddiff --git a/public/js/live-capture.js b/public/js/live-capture.js
ddeleted file mode 100644
iindex 95d20c52..00000000
---- a/public/js/live-capture.js
++++ /dev/null
@@@ -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;
--})();
ddiff --git a/server/cam/cam-client.js b/server/cam/cam-client.js
iindex 0229d085..9eb1c2e6 100644
---- a/server/cam/cam-client.js
++++ b/server/cam/cam-client.js
@@@ -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 };
ddiff --git a/server/cam/routes.js b/server/cam/routes.js
iindex 077110c4..cebe7ce5 100644
---- a/server/cam/routes.js
++++ b/server/cam/routes.js
@@@ -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);
ddiff --git a/server/index.js b/server/index.js
iindex 6162ee20..51292247 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
ddiff --git a/test/cam-button.test.js b/test/cam-button.test.js
iindex 87adbcff..0c10a9e9 100644
---- a/test/cam-button.test.js
++++ b/test/cam-button.test.js
@@@ -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 });
ddiff --git a/test/chat-msg-actions.test.js b/test/chat-msg-actions.test.js
iindex 1a315f5f..95650dba 100644
---- a/test/chat-msg-actions.test.js
++++ b/test/chat-msg-actions.test.js
@@@ -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(/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');
ddiff --git a/test/golive-camera-controls.test.js b/test/golive-camera-controls.test.js
nnew file mode 100644
iindex 00000000..5f2faab4
---- /dev/null
++++ b/test/golive-camera-controls.test.js
@@@ -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); });
ddiff --git a/test/golive-camera-help.test.js b/test/golive-camera-help.test.js
iindex 99ce4155..bfea7e7e 100644
---- a/test/golive-camera-help.test.js
++++ b/test/golive-camera-help.test.js
@@@ -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 ca
assert.ok(workspace.includes('id="gl-steps"') && workspace.includes('Press Go Live'), 'the three steps');
assert.ok(workspace.includes('id="gl-fix"'), 'the fix box');
--// The camera 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');
ddiff --git a/test/live-capture.test.js b/test/live-capture.test.js
ddeleted file mode 100644
iindex 72a00632..00000000
---- a/test/live-capture.test.js
++++ /dev/null
@@@ -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); });