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)
Changes
@@@ -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;@@@ -4,6 +4,7 @@top LIVE, time live, people watching, restreams, connection, Endright 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 pictureMore a sheet: share, watch as a viewer, any camera, screen, media requests, a longer clip, chat readaloud, 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');@@@ -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'));
@@ -2482,6 +2482,8 @@ body.bc-studio-on .bc-video-container {
@@ -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;
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; }
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. */
/* 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-container.bc-vertical-preview video { object-fit: cover; }
body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }
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; }
@@ -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-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-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-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 {
.bcs-flags {
position: absolute; top: calc(56px + env(safe-area-inset-top, 0px)); left: 12px; right: 84px;
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;
display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
top LIVE, time live, people watching, restreams, connection, End
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
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
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
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
aloud, restreams, stream health, all settings and the full page
@@ -58,6 +59,7 @@
@@ -58,6 +59,7 @@
if (!s) return;
if (!s) return;
for (const t of s.getAudioTracks()) if (t.enabled === st.micOff) t.enabled = !st.micOff;
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;
for (const t of s.getVideoTracks()) if (t.enabled === st.camOff) t.enabled = !st.camOff;
+ keepZoom();
}
}
function torchCapable() {
function torchCapable() {
const t = videoTrack();
const t = videoTrack();
@@ -71,6 +73,91 @@
@@ -71,6 +73,91 @@
return typeof _getCameraFacingHint === 'function' ? _getCameraFacingHint({ label: t.label }) : null;
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 toggleMic() { st.micOff = !st.micOff; applyTracks(); refresh(); }
function toggleCamera() { st.camOff = !st.camOff; applyTracks(); refresh(); }
function toggleCamera() { st.camOff = !st.camOff; applyTracks(); refresh(); }
async function toggleLight() {
async function toggleLight() {
@@ -98,6 +185,7 @@
@@ -98,6 +185,7 @@
const i = cams.findIndex((d) => d.deviceId === now.deviceId);
const i = cams.findIndex((d) => d.deviceId === now.deviceId);
const next = other || cams[(i + 1) % cams.length];
const next = other || cams[(i + 1) % cams.length];
st.torch = false;
st.torch = false;
+ zoom.level = null;
await _switchActiveCamera(next.deviceId);
await _switchActiveCamera(next.deviceId);
applyTracks();
applyTracks();
} catch (err) {
} catch (err) {
@@ -190,13 +278,15 @@
@@ -190,13 +278,15 @@
railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
railButton('more', 'fa-ellipsis', 'More', () => openSheet()));
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() {
function mount() {
document.body.classList.add('bc-studio-on');
document.body.classList.add('bc-studio-on');
st.root = build();
st.root = build();
document.body.append(st.root);
document.body.append(st.root);
+ attachZoom();
// The overlay chat replaces the phone chat sheet.
// The overlay chat replaces the phone chat sheet.
try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
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.
// The studio chat has no emote or GIF buttons: a picker opened before it would have no way to close.
@@ -213,6 +303,7 @@
@@ -213,6 +303,7 @@
closeConfirm();
closeConfirm();
closeViewer();
closeViewer();
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
+ detachZoom();
if (st.root) st.root.remove();
if (st.root) st.root.remove();
st.root = null;
st.root = null;
const v = $('bc-video-preview');
const v = $('bc-video-preview');
@@ -189,6 +189,70 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -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');
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 () => {
await check('the studio loads with the broadcast feature, after broadcast.js', async () => {
const js = features.broadcast.js;
const js = features.broadcast.js;
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));