openvibers/openvibe.live Commits

PiP: a portrait camera keeps its shape, plus edge snap, pinch, double-click size and keyboard (#651)

OP OpenVibers committed 6 days ago · 3a7297054e · 3 files, +251 −24

* PiP: the source's own shape (a portrait camera is no longer cropped to 16:9), edge snap, pinch, double-click size, keyboard

* Merge remote-tracking branch 'origin/main' into pip/portrait-and-controls

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 07802a68..fd548d15 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1268,7 +1268,11 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ov-pip.is-dragging, .ov-pip.is-resizing { user-select: none; }
.ov-pip.is-hidden { display: none; }
--.ov-pip-video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
++/* The box takes the source's own shape (stream-pip.js sets aspect-ratio from the video), so contain shows it whole: a
++ portrait camera is never cut to 16:9 (2026-11-07). */
++.ov-pip-video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
++.ov-pip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ov-pip.is-portrait .ov-pip-bar { padding-inline: 4px; }
.ov-pip:hover .ov-pip-bar, .ov-pip.is-dragging .ov-pip-bar { opacity: 1; pointer-events: auto; }
.ov-pip-title {
font-size: .72rem; color: #fff; font-weight: 600;
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
iindex 111dc5ab..2051fb4b 100644
---- a/public/js/stream-pip.js
++++ b/public/js/stream-pip.js
@@@ -12,6 +12,12 @@
(GET /api/streams/:id/pips), so one that goes live appears and one that ends goes. Geometry is kept as FRACTIONS
of the player box, remembered per stream and per PIP, so a viewer who drags one out of the way finds it there next
time. A PIP that cannot play reads as "offline" and never stops the stream underneath.
++
++ Shape (2026-11-07, Maticus: "portrait is displayed as cropped"): the box takes the source's own proportions from the
++ video (and follows a phone that turns), so a portrait camera is shown whole, never cut to 16:9. `w` is the width a
++ 16:9 PIP would have; any other shape gets the same AREA, and never more than 85% of the player's height. Dropped near
++ an edge it snaps to that edge; a double click (or tap) toggles small and large; two fingers pinch it; with focus,
++ the arrows move it, + and − size it, M mutes and H hides.
─────────────────────────────────────────────────────────────── */
(function () {
@@@ -21,6 +27,12 @@
const LS_OLD = (streamId) => `ov-pip-geom-${streamId}`; // the single PIP before 0021: the first PIP still reads it
const MIN_W = 0.08, MAX_W = 0.60;
const POLL_MS = 30000;
++ const BASE = 16 / 9; // the shape `w` is measured for
++ const MAX_H = 0.85; // of the player's height, whatever the shape
++ const SNAP = 0.04; // within this fraction of an edge, a dropped PIP snaps to it
++ const EDGE_PX = 12; // the margin it snaps to
++ const BIG_W = 0.5; // a double click's large size
++ const MIN_PX = 150; // the smallest 16:9 width on a phone-sized player
let host = null; // { streamId, container, overlays: Map(source_msid → overlay), timer, gen }
let gen = 0;
@@@ -56,14 +68,64 @@
try { localStorage.setItem(LS_KEY(o.hostId, o.pip.source_msid), JSON.stringify(o.geom)); } catch { /* */ }
}
++ /** The box as fractions of the player: width, height and the place it may go, for the source's own shape. */
++ function boxOf(o) {
++ const a = o.aspect || BASE;
++ const r = o.container ? o.container.getBoundingClientRect() : { width: 0, height: 0 };
++ // On a phone-sized player a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
++ // (the saved size is unchanged, so a desktop shows it as saved).
++ const w = r.width && r.width < 640 ? Math.max(o.geom.w, Math.min(MAX_W, MIN_PX / r.width)) : o.geom.w;
++ let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 PIP of width w
++ if (r.width && r.height && (wf * r.width) / a > MAX_H * r.height) wf = (MAX_H * r.height * a) / r.width;
++ wf = Math.min(wf, 1);
++ const hf = r.width && r.height ? (wf * r.width) / a / r.height : wf / a;
++ return { a, wf, hf, rw: r.width, rh: r.height };
++ }
++
function applyGeom(o) {
-- const { x, y, w, hidden } = o.geom;
-- o.el.style.left = `${x * 100}%`;
-- o.el.style.top = `${y * 100}%`;
-- o.el.style.width = `${w * 100}%`;
++ const { x, y, hidden } = o.geom;
++ const b = boxOf(o);
++ // Shown inside the player even when a turn of the phone or a smaller window would push it out; the saved
++ // place is not changed, so it comes back when there is room again.
++ o.el.style.left = `${Math.min(Math.max(0, x), Math.max(0, 1 - b.wf)) * 100}%`;
++ o.el.style.top = `${Math.min(Math.max(0, y), Math.max(0, 1 - b.hf)) * 100}%`;
++ o.el.style.width = `${b.wf * 100}%`;
++ o.el.style.aspectRatio = String(b.a);
++ o.el.classList.toggle('is-portrait', b.a < 1);
o.el.classList.toggle('is-hidden', !!hidden);
}
++ /** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
++ function settle(o) {
++ const b = boxOf(o);
++ const mx = b.rw ? EDGE_PX / b.rw : 0, my = b.rh ? EDGE_PX / b.rh : 0;
++ const maxX = Math.max(0, 1 - b.wf), maxY = Math.max(0, 1 - b.hf);
++ let { x, y } = o.geom;
++ x = Math.min(Math.max(0, x), maxX); y = Math.min(Math.max(0, y), maxY);
++ if (x < SNAP) x = Math.min(mx, maxX); else if (x > maxX - SNAP) x = Math.max(0, maxX - mx);
++ if (y < SNAP) y = Math.min(my, maxY); else if (y > maxY - SNAP) y = Math.max(0, maxY - my);
++ o.geom.x = x; o.geom.y = y;
++ }
++
++ /** The source's shape, from the video itself: on its first frame and whenever it changes (a phone turned). */
++ function watchShape(o, videoEl) {
++ const read = () => {
++ const vw = videoEl.videoWidth, vh = videoEl.videoHeight;
++ if (!vw || !vh) return;
++ const a = vw / vh;
++ if (o.aspect && Math.abs(o.aspect - a) < 0.01) return;
++ o.aspect = a;
++ if (o.el) applyGeom(o);
++ };
++ videoEl.addEventListener('loadedmetadata', read);
++ videoEl.addEventListener('resize', read);
++ }
++
++ function resetGeom(o) {
++ const d = o.pip.defaults || {};
++ o.geom.x = clamp(d.x, 0, 1, 0.72); o.geom.y = clamp(d.y, 0, 1, 0.70); o.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);
++ }
++
/* ── DOM ──────────────────────────────────────────────────── */
function escapeHtml(s) {
@@@ -97,18 +159,20 @@
<div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is offline</span></div>`;
container.appendChild(el);
o.el = el;
++ o.container = container;
++ el.tabIndex = 0;
++ el.setAttribute('role', 'group');
++ el.setAttribute('aria-label', `${label}, picture-in-picture. Arrow keys move it, plus and minus size it, M mutes it, H hides it.`);
muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);
++ watchShape(o, el.querySelector('.ov-pip-video'));
el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
e.stopPropagation();
if (act === 'hide') { o.geom.hidden = true; saveGeom(o); applyGeom(o); showRestore(o, container); }
-- else if (act === 'reset') {
-- const d = o.pip.defaults || {};
-- o.geom.x = clamp(d.x, 0, 1, 0.72); o.geom.y = clamp(d.y, 0, 1, 0.70); o.geom.w = clamp(d.w, MIN_W, MAX_W, 0.25);
-- saveGeom(o); applyGeom(o);
-- } else if (act === 'mute') {
++ else if (act === 'reset') { resetGeom(o); settle(o); saveGeom(o); applyGeom(o); }
++ else if (act === 'mute') {
o.geom.muted = !o.geom.muted;
const v = el.querySelector('.ov-pip-video');
v.muted = o.geom.muted;
@@@ -119,10 +183,43 @@
});
wireDrag(o, container);
wireResize(o, container);
++ wireKeys(o);
++ // A double click (a double tap on a phone) toggles small and large.
++ el.addEventListener('dblclick', (e) => {
++ if (e.target.closest('[data-act]') || e.target.closest('a') || e.target.closest('.ov-pip-resize')) return;
++ o.geom.w = o.geom.w < BIG_W - 0.01 ? BIG_W : clamp((o.pip.defaults || {}).w, MIN_W, MAX_W, 0.25);
++ settle(o); saveGeom(o); applyGeom(o);
++ });
applyGeom(o);
if (o.geom.hidden) showRestore(o, container);
}
++ /** With focus: arrows move it (Shift: further), + and − size it, M mutes, H hides, Home puts it back. */
++ function wireKeys(o) {
++ o.el.addEventListener('keydown', (e) => {
++ if (e.target !== o.el || e.altKey || e.ctrlKey || e.metaKey) return;
++ const step = e.shiftKey ? 0.1 : 0.02;
++ const k = e.key;
++ let moved = true;
++ if (k === 'ArrowLeft') o.geom.x -= step;
++ else if (k === 'ArrowRight') o.geom.x += step;
++ else if (k === 'ArrowUp') o.geom.y -= step;
++ else if (k === 'ArrowDown') o.geom.y += step;
++ else if (k === '+' || k === '=') o.geom.w = clamp(o.geom.w + 0.03, MIN_W, MAX_W, o.geom.w);
++ else if (k === '-' || k === '_') o.geom.w = clamp(o.geom.w - 0.03, MIN_W, MAX_W, o.geom.w);
++ else if (k === 'Home') resetGeom(o);
++ else if (k === 'm' || k === 'M') { o.el.querySelector('[data-act="mute"]').click(); moved = false; }
++ else if (k === 'h' || k === 'H') { o.el.querySelector('[data-act="hide"]').click(); moved = false; }
++ else return;
++ e.preventDefault();
++ if (!moved) return;
++ const b = boxOf(o);
++ o.geom.x = Math.min(Math.max(0, o.geom.x), Math.max(0, 1 - b.wf));
++ o.geom.y = Math.min(Math.max(0, o.geom.y), Math.max(0, 1 - b.hf));
++ saveGeom(o); applyGeom(o);
++ });
++ }
++
/** The "Show …" pill for a hidden PIP; pills of several hidden PIPs sit side by side. */
function showRestore(o, container) {
if (o.pill) return;
@@@ -145,31 +242,52 @@
if (bar && !bar.children.length) bar.remove();
}
-- /** Pointer-driven move, constrained so the overlay can never be dragged off-screen. */
++ /**
++ * Pointer-driven move, constrained so the overlay can never be dragged off-screen, and snapped to an edge when
++ * dropped near one. Two fingers on it pinch it bigger or smaller.
++ */
function wireDrag(o, container) {
const el = o.el;
let start = null;
++ const touches = new Map(); // pointerId → { x, y } while two fingers pinch
++ let pinch = null;
++ const gap = () => { const [a, b] = [...touches.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
const onDown = (e) => {
if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize') || e.target.closest('a')) return;
++ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ el.setPointerCapture?.(e.pointerId);
++ e.preventDefault();
++ if (touches.size === 2) { start = null; pinch = { d: gap(), w: o.geom.w }; el.classList.add('is-resizing'); return; }
++ if (touches.size > 2) return;
const r = container.getBoundingClientRect();
start = { px: e.clientX, py: e.clientY, x: o.geom.x, y: o.geom.y, rw: r.width, rh: r.height };
el.classList.add('is-dragging');
-- el.setPointerCapture?.(e.pointerId);
-- e.preventDefault();
};
const onMove = (e) => {
++ if (touches.has(e.pointerId)) touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ if (pinch && touches.size === 2) {
++ o.geom.w = clamp(pinch.w * (gap() / pinch.d), MIN_W, MAX_W, pinch.w);
++ applyGeom(o);
++ return;
++ }
if (!start) return;
-- const box = el.getBoundingClientRect();
-- const r = container.getBoundingClientRect();
-- o.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, 1 - (box.width / r.width)));
-- o.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, 1 - (box.height / r.height)));
++ const b = boxOf(o);
++ o.geom.x = Math.min(Math.max(0, start.x + (e.clientX - start.px) / start.rw), Math.max(0, 1 - b.wf));
++ o.geom.y = Math.min(Math.max(0, start.y + (e.clientY - start.py) / start.rh), Math.max(0, 1 - b.hf));
applyGeom(o);
};
const onUp = (e) => {
++ touches.delete(e.pointerId);
++ el.releasePointerCapture?.(e.pointerId);
++ if (pinch) {
++ if (touches.size < 2) { pinch = null; el.classList.remove('is-resizing'); settle(o); applyGeom(o); saveGeom(o); }
++ return;
++ }
if (!start) return;
start = null;
el.classList.remove('is-dragging');
-- el.releasePointerCapture?.(e.pointerId);
++ settle(o);
++ applyGeom(o);
saveGeom(o);
};
el.addEventListener('pointerdown', onDown);
@@@ -191,11 +309,13 @@
});
handle.addEventListener('pointermove', (e) => {
if (!start) return;
-- o.geom.w = clamp(start.w + (e.clientX - start.px) / start.rw, MIN_W, MAX_W, start.w);
++ // The handle's sideways move is the box's own width; `w` is the 16:9 width of the same area.
++ const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;
++ o.geom.w = clamp(start.w + (e.clientX - start.px) / start.rw / k, MIN_W, MAX_W, start.w);
// Keep it inside the player as it grows.
-- const box = el.getBoundingClientRect(), r = container.getBoundingClientRect();
-- o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - box.width / r.width));
-- o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - box.height / r.height));
++ const b = boxOf(o);
++ o.geom.x = Math.min(o.geom.x, Math.max(0, 1 - b.wf));
++ o.geom.y = Math.min(o.geom.y, Math.max(0, 1 - b.hf));
applyGeom(o);
});
const end = (e) => {
@@@ -361,19 +481,26 @@
const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays
: (stream.pip_overlay ? [Object.assign({ source_msid: stream.pip_overlay.source_msid || 0 }, stream.pip_overlay)] : []);
if (!pips.length) return; // a slot with no PIPs never polls
-- host = { streamId: stream.id, container, overlays: new Map(), gen: ++gen, timer: null };
++ host = { streamId: stream.id, container, overlays: new Map(), gen: ++gen, timer: null, ro: null };
sync(pips);
host.timer = setInterval(poll, POLL_MS);
++ // The player changes size (a window, theatre mode, fullscreen, a phone turned): the cap on height follows.
++ if (typeof ResizeObserver === 'function') {
++ const h = host;
++ h.ro = new ResizeObserver(() => { if (host === h) for (const o of h.overlays.values()) if (o.el) applyGeom(o); });
++ h.ro.observe(container);
++ }
}
function detach() {
gen++;
if (!host) return;
clearInterval(host.timer);
++ try { host.ro?.disconnect(); } catch { /* */ }
for (const o of host.overlays.values()) close(o);
try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }
host = null;
}
-- window.streamPip = { attach, detach };
++ window.streamPip = { attach, detach, _test: { boxOf, settle, applyGeom, BASE, MAX_H } };
})();
ddiff --git a/test/stream-pip-shape.test.js b/test/stream-pip-shape.test.js
nnew file mode 100644
iindex 00000000..871d193d
---- /dev/null
++++ b/test/stream-pip-shape.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++// A PIP takes its source's own shape (public/js/stream-pip.js; 2026-11-07, Maticus: "portrait is displayed as
++// cropped, not actual portrait dimensions"). What this proves:
++// - a 9:16 camera is drawn 9:16 (aspect-ratio from the video, object-fit contain), never cut to 16:9;
++// - any shape keeps the area of a 16:9 PIP of the saved width, and is never taller than 85% of the player;
++// - the shown place is kept inside the player without changing the saved place;
++// - dropped near an edge it snaps to a 12 px margin; dropped in the middle it stays put;
++// - on a phone-sized player it is never smaller than a 150 px wide 16:9 box.
++// node test/stream-pip-shape.test.js
++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', 'stream-pip.js'), 'utf8');
++const ctx = { window: {}, document: {}, localStorage: { getItem: () => null, setItem() {} }, console, setInterval, clearInterval };
++vm.createContext(ctx);
++vm.runInContext(src, ctx);
++const { boxOf, settle, applyGeom } = ctx.window.streamPip._test;
++
++const player = (width, height) => ({ getBoundingClientRect: () => ({ width, height }) });
++const el = () => {
++ const classes = new Set();
++ return { style: {}, classList: { toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), has: (c) => classes.has(c) } };
++};
++const pip = (aspect, geom, w = 1280, h = 720) => ({ aspect, geom: { x: 0.7, y: 0.7, w: 0.25, hidden: false, ...geom }, container: player(w, h), el: el(), pip: {} });
++const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`);
++
++let n = 0;
++const check = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
++
++check('a portrait camera keeps its shape and the area of a 16:9 PIP', () => {
++ const land = boxOf(pip(16 / 9, {}));
++ const port = boxOf(pip(9 / 16, {}));
++ near(land.wf, 0.25, 'the 16:9 width is w');
++ const area = (b) => (b.wf * 1280) * ((b.wf * 1280) / b.a);
++ near(area(port), area(land), 'same area in pixels');
++ assert.ok(port.hf > port.wf, 'taller than wide');
++ near((port.wf * 1280) / (port.hf * 720), 9 / 16, 'drawn 9:16');
++});
++
++check('applyGeom draws the source shape and marks a portrait one', () => {
++ const o = pip(9 / 16, {});
++ applyGeom(o);
++ assert.strictEqual(o.el.style.aspectRatio, String(9 / 16));
++ assert.ok(o.el.classList.has('is-portrait'));
++ const l = pip(16 / 9, {});
++ applyGeom(l);
++ assert.ok(!l.el.classList.has('is-portrait'));
++});
++
++check('never taller than 85% of the player', () => {
++ const b = boxOf(pip(9 / 16, { w: 0.6 }));
++ assert.ok(b.hf <= 0.85 + 1e-9, `height ${b.hf}`);
++ near(b.hf, 0.85, 'capped at the limit');
++});
++
++check('shown inside the player; the saved place is unchanged', () => {
++ const o = pip(9 / 16, { x: 0.95, y: 0.95 });
++ applyGeom(o);
++ const b = boxOf(o);
++ near(parseFloat(o.el.style.left) / 100, 1 - b.wf, 'left pulled in');
++ near(parseFloat(o.el.style.top) / 100, 1 - b.hf, 'top pulled in');
++ assert.strictEqual(o.geom.x, 0.95, 'saved x kept');
++});
++
++check('a drop near an edge snaps to a 12 px margin; the middle stays put', () => {
++ const o = pip(16 / 9, { x: 0.01, y: 0.02 });
++ settle(o);
++ near(o.geom.x, 12 / 1280, 'left edge');
++ near(o.geom.y, 12 / 720, 'top edge');
++ const b = boxOf(o);
++ const r = pip(16 / 9, { x: 1 - b.wf - 0.01, y: 1 - b.hf - 0.01 });
++ settle(r);
++ near(r.geom.x, 1 - b.wf - 12 / 1280, 'right edge');
++ near(r.geom.y, 1 - b.hf - 12 / 720, 'bottom edge');
++ const m = pip(16 / 9, { x: 0.4, y: 0.3 });
++ settle(m);
++ assert.strictEqual(m.geom.x, 0.4); assert.strictEqual(m.geom.y, 0.3);
++});
++
++check('on a phone-sized player it is never smaller than a 150 px wide 16:9 box', () => {
++ const b = boxOf(pip(16 / 9, { w: 0.25 }, 390, 219));
++ near(b.wf * 390, 150, '150 px wide');
++ const p = boxOf(pip(9 / 16, { w: 0.25 }, 390, 219));
++ near((p.wf * 390) * ((p.wf * 390) / p.a), 150 * (150 / (16 / 9)), 'a portrait one keeps that area');
++ near(boxOf(pip(16 / 9, { w: 0.25 })).wf, 0.25, 'a desktop player shows the saved size');
++});
++
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');
++check('the video is contained, never cropped', () => {
++ assert.match(css, /\.ov-pip-video \{[^}]*object-fit: contain/);
++ assert.doesNotMatch(css, /\.ov-pip-video \{[^}]*object-fit: cover/);
++});
++
++console.log(`stream-pip shape: ${n} passed`);