openvibers/openvibe.live Commits

Pinch to zoom while live on a phone: two fingers on the picture zoom the camera itself (a double tap is 1x), held across a recovery, reset on a flip (#277)

OP OpenVibers committed 24 days ago · 7d58699eb6 · 3 files, +163 −1

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex ec7bb767..c49b309d 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2482,6 +2482,8 @@ body.bc-studio-on .bc-video-container {
min-height: 0 !important; max-height: none !important; border: 0; border-radius: 0; background: #000; aspect-ratio: auto;
}
body.bc-studio-on .bc-video-container video { width: 100%; height: 100%; max-height: none !important; object-fit: contain; }
++/* Pinch on the picture zooms the camera (broadcast-studio.js), not the page. */
++body.bc-studio-on .bc-video-container { touch-action: none; }
/* A vertical camera on a vertical screen fills it, the way phone camera apps show it. */
body.bc-studio-on .bc-video-container.bc-vertical-preview video { object-fit: cover; }
body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }
@@@ -2569,6 +2571,11 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-restream::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.bcs-pill.is-bad { border-color: rgba(239,68,68,0.7); }
.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }
++.bcs-zoom {
++ position: absolute; top: 38%; left: 50%; transform: translate(-50%, -50%); padding: 8px 16px; border-radius: 999px;
++ background: rgba(0,0,0,0.55); font-size: 1.1rem; font-weight: 800; font-variant-numeric: tabular-nums; pointer-events: none;
++}
++.bcs-zoom[hidden] { display: none; }
.bcs-flags {
position: absolute; top: calc(56px + env(safe-area-inset-top, 0px)); left: 12px; right: 84px;
display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 4b61fbf3..2cb48e3c 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -4,6 +4,7 @@
top LIVE, time live, people watching, restreams, connection, End
right Flip (front ↔ back in one tap), Mic, Camera, Light (a back camera's torch), Chat, Clip, More
++ picture pinch to zoom the camera, double tap for 1×
bottom the stream chat, over the picture
More a sheet: share, watch as a viewer, any camera, screen, media requests, a longer clip, chat read
aloud, restreams, stream health, all settings and the full page
@@@ -58,6 +59,7 @@
if (!s) return;
for (const t of s.getAudioTracks()) if (t.enabled === st.micOff) t.enabled = !st.micOff;
for (const t of s.getVideoTracks()) if (t.enabled === st.camOff) t.enabled = !st.camOff;
++ keepZoom();
}
function torchCapable() {
const t = videoTrack();
@@@ -71,6 +73,91 @@
return typeof _getCameraFacingHint === 'function' ? _getCameraFacingHint({ label: t.label }) : null;
}
++ // ── Pinch to zoom (owner, 2026-10-20: "some way to zoom while you're live on phone") ──
++ // Two fingers on the picture drive the camera's own zoom (MediaStreamTrack `zoom`: the lens or a sensor crop, so
++ // viewers get a sharp picture, never a stretched one); a double tap goes back to 1×. A camera without zoom says so
++ // once. The level holds across a replaced track (a recovery on the same camera) and resets when the camera changes.
++ const zoom = { level: null, deviceId: null, startLevel: 0, startDist: 0, busy: false, pending: null, lastTap: 0, told: false, badgeTimer: 0, surface: null };
++ function zoomRange() {
++ const t = videoTrack();
++ try { const c = t && typeof t.getCapabilities === 'function' && t.getCapabilities().zoom; return c && c.max > c.min ? c : null; } catch { return null; }
++ }
++ function trackZoom() { const t = videoTrack(); const s = (t && t.getSettings && t.getSettings()) || {}; return Number(s.zoom) || (zoomRange() || {}).min || 1; }
++ function trackDevice() { const t = videoTrack(); return (t && t.getSettings && t.getSettings().deviceId) || null; }
++ function showZoom(z) {
++ const b = $('bcs-zoom');
++ if (!b) return;
++ b.textContent = `${(Math.round(z * 10) / 10).toFixed(1)}×`;
++ b.hidden = false;
++ clearTimeout(zoom.badgeTimer);
++ zoom.badgeTimer = setTimeout(() => { b.hidden = true; }, 1200);
++ }
++ function setZoom(level) {
++ const r = zoomRange();
++ const t = videoTrack();
++ if (!r || !t) return;
++ const step = r.step > 0 ? r.step : 0.1;
++ const z = Math.min(r.max, Math.max(r.min, r.min + Math.round((level - r.min) / step) * step));
++ zoom.level = z;
++ zoom.deviceId = trackDevice();
++ showZoom(z);
++ if (zoom.busy) { zoom.pending = z; return; } // one applyConstraints at a time; the newest level wins
++ zoom.busy = true;
++ t.applyConstraints({ advanced: [{ zoom: z }] }).catch(() => {}).finally(() => {
++ zoom.busy = false;
++ if (zoom.pending != null) { const next = zoom.pending; zoom.pending = null; setZoom(next); }
++ });
++ }
++ const fingers = (e) => Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
++ function onZoomTouchStart(e) {
++ if (e.touches.length === 2) {
++ if (!zoomRange()) {
++ if (!zoom.told) { zoom.told = true; say('This camera cannot zoom'); }
++ return;
++ }
++ e.preventDefault();
++ zoom.startDist = fingers(e);
++ zoom.startLevel = zoom.level != null ? zoom.level : trackZoom();
++ } else if (e.touches.length === 1) {
++ const now = Date.now();
++ const r = zoomRange();
++ if (r && now - zoom.lastTap < 320) { e.preventDefault(); zoom.lastTap = 0; setZoom(r.min); } else zoom.lastTap = now;
++ }
++ }
++ function onZoomTouchMove(e) {
++ if (e.touches.length !== 2 || !zoom.startDist) return;
++ e.preventDefault();
++ setZoom(zoom.startLevel * (fingers(e) / zoom.startDist));
++ }
++ function onZoomTouchEnd(e) { if (e.touches.length < 2) zoom.startDist = 0; }
++ function attachZoom() {
++ const v = $('bc-video-preview');
++ const surface = v && v.closest('.bc-video-container');
++ if (!surface || zoom.surface === surface) return;
++ detachZoom();
++ zoom.surface = surface;
++ surface.addEventListener('touchstart', onZoomTouchStart, { passive: false });
++ surface.addEventListener('touchmove', onZoomTouchMove, { passive: false });
++ surface.addEventListener('touchend', onZoomTouchEnd);
++ surface.addEventListener('touchcancel', onZoomTouchEnd);
++ }
++ function detachZoom() {
++ const s = zoom.surface;
++ if (!s) return;
++ s.removeEventListener('touchstart', onZoomTouchStart);
++ s.removeEventListener('touchmove', onZoomTouchMove);
++ s.removeEventListener('touchend', onZoomTouchEnd);
++ s.removeEventListener('touchcancel', onZoomTouchEnd);
++ zoom.surface = null;
++ }
++ /** The chosen zoom, again on a replaced track of the same camera; forgotten when the camera changes. */
++ function keepZoom() {
++ if (zoom.level == null || zoom.busy) return;
++ if (trackDevice() !== zoom.deviceId) { zoom.level = null; return; }
++ const r = zoomRange();
++ if (r && Math.abs(trackZoom() - zoom.level) > (r.step > 0 ? r.step : 0.1) / 2) setZoom(zoom.level);
++ }
++
function toggleMic() { st.micOff = !st.micOff; applyTracks(); refresh(); }
function toggleCamera() { st.camOff = !st.camOff; applyTracks(); refresh(); }
async function toggleLight() {
@@@ -98,6 +185,7 @@
const i = cams.findIndex((d) => d.deviceId === now.deviceId);
const next = other || cams[(i + 1) % cams.length];
st.torch = false;
++ zoom.level = null;
await _switchActiveCamera(next.deviceId);
applyTracks();
} catch (err) {
@@@ -190,13 +278,15 @@
railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
railButton('more', 'fa-ellipsis', 'More', () => openSheet()));
-- return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail);
++ return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail,
++ el('div', { class: 'bcs-zoom', id: 'bcs-zoom', hidden: true, 'aria-live': 'polite' }));
}
function mount() {
document.body.classList.add('bc-studio-on');
st.root = build();
document.body.append(st.root);
++ attachZoom();
// The overlay chat replaces the phone chat sheet.
try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
// The studio chat has no emote or GIF buttons: a picker opened before it would have no way to close.
@@@ -213,6 +303,7 @@
closeConfirm();
closeViewer();
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
++ detachZoom();
if (st.root) st.root.remove();
st.root = null;
const v = $('bc-video-preview');
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
iindex fcf6397c..ed5a3e55 100644
---- a/test/broadcast-studio.test.js
++++ b/test/broadcast-studio.test.js
@@@ -189,6 +189,70 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
assert.ok(/height: min\(24dvh, 180px\)/.test(css), 'the studio chat keeps to the bottom quarter');
});
++ await check('pinch on the picture zooms the camera itself; a double tap is 1x; it holds on a recovery, resets on a flip', async () => {
++ // The zoom code from the studio itself, with a camera whose track can zoom 1-8x in 0.1 steps.
++ const fnNames = ['zoomRange', 'trackZoom', 'trackDevice', 'showZoom', 'setZoom', 'onZoomTouchStart', 'onZoomTouchMove', 'onZoomTouchEnd', 'keepZoom'];
++ const zoomDecl = /const zoom = \{[^\n]*\};/.exec(studioSrc)[0];
++ const fingersDecl = /const fingers = [^\n]*;/.exec(studioSrc)[0];
++ const applied = [];
++ const toasts = [];
++ let current = { zoom: 1, deviceId: 'back' };
++ let caps = { zoom: { min: 1, max: 8, step: 0.1 } };
++ const cam = { getCapabilities: () => caps, getSettings: () => current, applyConstraints: async (c) => { applied.push(c.advanced[0].zoom); current = { ...current, zoom: c.advanced[0].zoom }; } };
++ const badge = { hidden: true, textContent: '' };
++ const body = `${zoomDecl}\n${fingersDecl}\n${fnNames.map((n) => extract(studioSrc, n)).join('\n')}\nreturn { zoom, ${fnNames.join(', ')} };`;
++ // eslint-disable-next-line no-new-func
++ const z = new Function('videoTrack', '$', 'say', 'setTimeout', 'clearTimeout', 'Date', body)(() => cam, (id) => (id === 'bcs-zoom' ? badge : null), (m) => toasts.push(m), () => 0, () => {}, Date);
++ const settle = () => new Promise((r) => setImmediate(r));
++ const touch = (...pts) => ({ touches: pts.map(([x, y]) => ({ clientX: x, clientY: y })), prevented: false, preventDefault() { this.prevented = true; } });
++
++ const start = touch([100, 300], [200, 300]);
++ z.onZoomTouchStart(start);
++ assert.ok(start.prevented, 'the page does not zoom instead');
++ z.onZoomTouchMove(touch([50, 300], [250, 300])); // fingers twice as far apart
++ await settle();
++ assert.strictEqual(applied[applied.length - 1], 2, 'twice the distance is 2x, on the camera itself');
++ assert.strictEqual(badge.textContent, '2.0×');
++ z.onZoomTouchMove(touch([0, 300], [1000, 300])); // far past the camera's max
++ await settle();
++ assert.strictEqual(applied[applied.length - 1], 8, 'clamped to what the camera can do');
++ z.onZoomTouchEnd(touch());
++
++ // A double tap goes back to 1x.
++ z.onZoomTouchStart(touch([10, 10]));
++ const second = touch([10, 10]);
++ z.onZoomTouchStart(second);
++ await settle();
++ assert.ok(second.prevented);
++ assert.strictEqual(applied[applied.length - 1], 1);
++
++ // Held when a recovery hands the same camera a fresh track at 1x; forgotten when the camera changes.
++ z.setZoom(3);
++ await settle();
++ current = { zoom: 1, deviceId: 'back' };
++ z.keepZoom();
++ await settle();
++ assert.strictEqual(applied[applied.length - 1], 3, 'the same camera gets its zoom back');
++ current = { zoom: 1, deviceId: 'front' };
++ const before = applied.length;
++ z.keepZoom();
++ await settle();
++ assert.strictEqual(applied.length, before, 'another camera starts at its own 1x');
++ assert.strictEqual(z.zoom.level, null);
++
++ // A camera without zoom says so, once, and the page is left alone.
++ caps = {};
++ const noZoom = touch([100, 300], [200, 300]);
++ z.onZoomTouchStart(noZoom);
++ z.onZoomTouchStart(touch([100, 300], [200, 300]));
++ assert.deepStrictEqual(toasts, ['This camera cannot zoom']);
++ assert.strictEqual(noZoom.prevented, false);
++
++ assert.ok(/zoom\.level = null;\s*await _switchActiveCamera\(next\.deviceId\)/.test(extract(studioSrc, 'flip')), 'a flip starts the new camera at 1x');
++ assert.ok(/attachZoom\(\)/.test(extract(studioSrc, 'mount')) && /detachZoom\(\)/.test(extract(studioSrc, 'unmount')));
++ assert.ok(/body\.bc-studio-on \.bc-video-container \{ touch-action: none; \}/.test(read('public/css/features/broadcast.css')), 'the browser leaves the pinch to the studio');
++ });
++
await check('the studio loads with the broadcast feature, after broadcast.js', async () => {
const js = features.broadcast.js;
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));