openvibers/openvibe.live Commits
PiP: a portrait camera keeps its shape, plus edge snap, pinch, double-click size and keyboard (#651)
Changes
@@@ -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;@@@ -12,6 +12,12 @@(GET /api/streams/:id/pips), so one that goes live appears and one that ends goes. Geometry is kept as FRACTIONSof the player box, remembered per stream and per PIP, so a viewer who drags one out of the way finds it there nexttime. 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 itconst 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 playerlet 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 } };})();nnew file mode 100644@@@ -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`);
@@ -1268,7 +1268,11 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -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-dragging, .ov-pip.is-resizing { user-select: none; }
.ov-pip.is-hidden { display: 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:hover .ov-pip-bar, .ov-pip.is-dragging .ov-pip-bar { opacity: 1; pointer-events: auto; }
.ov-pip-title {
.ov-pip-title {
font-size: .72rem; color: #fff; font-weight: 600;
font-size: .72rem; color: #fff; font-weight: 600;
@@ -12,6 +12,12 @@
@@ -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
(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
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.
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 () {
(function () {
@@ -21,6 +27,12 @@
@@ -21,6 +27,12 @@
const LS_OLD = (streamId) => `ov-pip-geom-${streamId}`; // the single PIP before 0021: the first PIP still reads it
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 MIN_W = 0.08, MAX_W = 0.60;
const POLL_MS = 30000;
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 host = null; // { streamId, container, overlays: Map(source_msid → overlay), timer, gen }
let gen = 0;
let gen = 0;
@@ -56,14 +68,64 @@
@@ -56,14 +68,64 @@
try { localStorage.setItem(LS_KEY(o.hostId, o.pip.source_msid), JSON.stringify(o.geom)); } catch { /* */ }
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) {
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);
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 ──────────────────────────────────────────────────── */
/* ── DOM ──────────────────────────────────────────────────── */
function escapeHtml(s) {
function escapeHtml(s) {
@@ -97,18 +159,20 @@
@@ -97,18 +159,20 @@
<div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is offline</span></div>`;
<div class="ov-pip-offline"><i class="fa-solid fa-video-slash"></i><span>${escapeHtml(label)} is offline</span></div>`;
container.appendChild(el);
container.appendChild(el);
o.el = 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);
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) => {
el.querySelector('.ov-pip-actions').addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
if (!act) return;
e.stopPropagation();
e.stopPropagation();
if (act === 'hide') { o.geom.hidden = true; saveGeom(o); applyGeom(o); showRestore(o, container); }
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;
o.geom.muted = !o.geom.muted;
const v = el.querySelector('.ov-pip-video');
const v = el.querySelector('.ov-pip-video');
v.muted = o.geom.muted;
v.muted = o.geom.muted;
@@ -119,10 +183,43 @@
@@ -119,10 +183,43 @@
});
});
wireDrag(o, container);
wireDrag(o, container);
wireResize(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);
applyGeom(o);
if (o.geom.hidden) showRestore(o, container);
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. */
/** The "Show …" pill for a hidden PIP; pills of several hidden PIPs sit side by side. */
function showRestore(o, container) {
function showRestore(o, container) {
if (o.pill) return;
if (o.pill) return;
@@ -145,31 +242,52 @@
@@ -145,31 +242,52 @@
if (bar && !bar.children.length) bar.remove();
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) {
function wireDrag(o, container) {
const el = o.el;
const el = o.el;
let start = null;
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) => {
const onDown = (e) => {
if (e.target.closest('[data-act]') || e.target.closest('.ov-pip-resize') || e.target.closest('a')) return;
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();
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 };
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.classList.add('is-dragging');
- el.setPointerCapture?.(e.pointerId);
- e.preventDefault();
};
};
const onMove = (e) => {
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;
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);
applyGeom(o);
};
};
const onUp = (e) => {
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;
if (!start) return;
start = null;
start = null;
el.classList.remove('is-dragging');
el.classList.remove('is-dragging');
- el.releasePointerCapture?.(e.pointerId);
+ settle(o);
+ applyGeom(o);
saveGeom(o);
saveGeom(o);
};
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointerdown', onDown);
@@ -191,11 +309,13 @@
@@ -191,11 +309,13 @@
});
});
handle.addEventListener('pointermove', (e) => {
handle.addEventListener('pointermove', (e) => {
if (!start) return;
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.
// 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);
applyGeom(o);
});
});
const end = (e) => {
const end = (e) => {
@@ -361,19 +481,26 @@
@@ -361,19 +481,26 @@
const pips = Array.isArray(stream.pip_overlays) ? stream.pip_overlays
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)] : []);
: (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
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);
sync(pips);
host.timer = setInterval(poll, POLL_MS);
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() {
function detach() {
gen++;
gen++;
if (!host) return;
if (!host) return;
clearInterval(host.timer);
clearInterval(host.timer);
+ try { host.ro?.disconnect(); } catch { /* */ }
for (const o of host.overlays.values()) close(o);
for (const o of host.overlays.values()) close(o);
try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }
try { host.container.querySelector('.ov-pip-restores')?.remove(); } catch { /* */ }
host = null;
host = null;
}
}
- window.streamPip = { attach, detach };
+ window.streamPip = { attach, detach, _test: { boxOf, settle, applyGeom, BASE, MAX_H } };
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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`);