openvibers/openvibe.live Commits

Phone cameras open with zoom available: every camera request on a touch screen asks for zoom (a wish, not a requirement), since Chrome lists a camera's zoom only when asked, so the pinch zooms what viewers see (#279)

OP OpenVibers committed 24 days ago · 27b37976d6 · 2 files, +117 −1

Changes

ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 62a4c471..8703e4c8 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -255,6 +255,23 @@ async function populateCreateFormDevices() {
} catch (err) { console.warn('Could not enumerate devices for create form:', err.message); }
}
++/**
++ * The phone studio's pinch zooms the camera itself (MediaStreamTrack `zoom`, broadcast-studio.js), and Chrome lists
++ * a camera's zoom in getCapabilities() only when the request asked for it. So on a touch screen, in a browser that
++ * knows the constraint, every camera request asks: `zoom: true` is a wish, not a requirement (a camera without zoom
++ * still opens). Without it the pinch said "This camera cannot zoom" on phones whose camera can (owner, 2026-10-20).
++ */
++function _withCameraZoom(constraints) {
++ const video = constraints && constraints.video;
++ if (!video) return constraints;
++ try {
++ if (!navigator.mediaDevices.getSupportedConstraints?.().zoom) return constraints;
++ if (!window.matchMedia?.('(pointer: coarse)').matches) return constraints;
++ } catch { return constraints; }
++ if (video === true) return { ...constraints, video: { zoom: true } };
++ return 'zoom' in video ? constraints : { ...constraints, video: { ...video, zoom: true } };
++}
++
/**
* Helper: call getUserMedia with a timeout to avoid indefinite hangs on mobile.
* Also handles Android-specific quirks:
@@@ -262,13 +279,19 @@ async function populateCreateFormDevices() {
* - OverconstrainedError gets retried with relaxed constraints
*/
function _getUserMediaWithTimeout(constraints, timeoutMs = 15000) {
++ const asked = _withCameraZoom(constraints);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Camera/mic request timed out — try tapping Allow in the browser prompt')), timeoutMs);
-- navigator.mediaDevices.getUserMedia(constraints).then(stream => {
++ navigator.mediaDevices.getUserMedia(asked).then(stream => {
clearTimeout(timer);
resolve(stream);
}).catch(err => {
clearTimeout(timer);
++ // A browser that throws on the zoom wish: ask again as the caller asked.
++ if (asked !== constraints && err.name === 'TypeError') {
++ navigator.mediaDevices.getUserMedia(constraints).then(resolve).catch(reject);
++ return;
++ }
// On OverconstrainedError, retry with relaxed constraints
if (err.name === 'OverconstrainedError') {
const relaxed = {};
ddiff --git a/test/broadcast-camera-zoom.test.js b/test/broadcast-camera-zoom.test.js
nnew file mode 100644
iindex 00000000..ee76d90b
---- /dev/null
++++ b/test/broadcast-camera-zoom.test.js
@@@ -0,0 +1,93 @@
++/**
++ * The phone studio's pinch zooms the camera itself (Live#277), and Chrome lists a camera's zoom only for a track whose
++ * getUserMedia request asked for it. Every camera request goes through _getUserMediaWithTimeout
++ * (public/js/broadcast-devices.js), which adds `zoom: true` on a touch screen in a browser that knows the constraint.
++ *
++ * node test/broadcast-camera-zoom.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/broadcast-devices.js'), 'utf8');
++
++/** The source of a top-level function declaration, braces matched (after its parameter list). */
++function extract(name) {
++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
++ assert.ok(m, `${name}() must exist`);
++ let i = m.index + m[0].length;
++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
++ i = src.indexOf('{', i);
++ for (let depth = 0; i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++
++/** A browser: touch screen or not, knows `zoom` or not, and getUserMedia answers from `answer(constraints)`. */
++function browser({ coarse = true, knowsZoom = true, answer = () => ({ id: 'stream' }) } = {}) {
++ const asked = [];
++ const ctx = {
++ console: { warn() {} },
++ setTimeout, clearTimeout,
++ window: { matchMedia: (q) => ({ matches: q === '(pointer: coarse)' && coarse }) },
++ navigator: {
++ mediaDevices: {
++ getSupportedConstraints: () => (knowsZoom ? { deviceId: true, zoom: true } : { deviceId: true }),
++ getUserMedia: async (c) => { asked.push(c); return answer(c, asked.length); },
++ },
++ },
++ };
++ vm.createContext(ctx);
++ vm.runInContext(`${extract('_withCameraZoom')}\n${extract('_getUserMediaWithTimeout')}\nthis.gum = _getUserMediaWithTimeout;`, ctx);
++ return { gum: ctx.gum, asked };
++}
++
++(async () => {
++ // A phone opening the back camera for going live: the zoom wish rides along, everything else as asked.
++ const phone = browser();
++ const live = { video: { deviceId: { exact: 'back' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: { echoCancellation: true } };
++ await phone.gum(live);
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(phone.asked[0])), {
++ video: { deviceId: { exact: 'back' }, width: { ideal: 1280 }, height: { ideal: 720 }, zoom: true },
++ audio: { echoCancellation: true },
++ });
++ assert.ok(!('zoom' in live.video), "the caller's constraints are not changed");
++
++ // The bare fallbacks ask too, so a recovered track can still zoom.
++ await phone.gum({ video: true, audio: true });
++ assert.deepStrictEqual(JSON.parse(JSON.stringify(phone.asked[1])), { video: { zoom: true }, audio: true });
++
++ // A microphone-only request is left alone.
++ const mic = { audio: true, video: false };
++ await phone.gum(mic);
++ assert.strictEqual(phone.asked[2], mic);
++
++ // A desktop (no pinch there) and a browser that doesn't know the constraint ask as before.
++ const desktop = browser({ coarse: false });
++ await desktop.gum(live);
++ assert.strictEqual(desktop.asked[0], live, 'no zoom (and no pan-tilt-zoom prompt) on a desktop');
++ const older = browser({ knowsZoom: false });
++ await older.gum(live);
++ assert.strictEqual(older.asked[0], live);
++
++ // A browser that throws on the wish is asked again as the caller asked.
++ const picky = browser({ answer: (c, n) => { if (n === 1) { const e = new TypeError('bad constraint'); throw e; } return { id: 'second' }; } });
++ assert.deepStrictEqual(await picky.gum(live), { id: 'second' });
++ assert.strictEqual(picky.asked.length, 2);
++ assert.strictEqual(picky.asked[1], live);
++
++ // A refusal is final: no second permission prompt.
++ const refused = browser({ answer: () => { const e = new Error('denied'); e.name = 'NotAllowedError'; throw e; } });
++ await assert.rejects(refused.gum(live), /denied/);
++ assert.strictEqual(refused.asked.length, 1);
++
++ // The studio's pinch reads the zoom this request unlocks.
++ const studio = fs.readFileSync(path.join(__dirname, '..', 'public/js/broadcast-studio.js'), 'utf8');
++ assert.match(studio, /getCapabilities\(\)\.zoom/);
++
++ console.log('broadcast camera zoom: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });