openvibers/openvibe.live Commits
The live player takes the stream's own shape: any ratio, eased on a turn, steady through resolution drops (#372)
Changes
@@@ -43,6 +43,8 @@ You don't need any of the following:only.- A phone sends H.264, which its hardware encodes, so the stream uses less battery and keeps its frame rate. When thenetwork or the phone gets busy, the picture gets softer before it gets choppy.++- **Turn the phone whenever you like.** Viewers' players take your picture's shape, upright or sideways, and ease from++ one to the other when you turn. The recording keeps each part in its own shape too.- **End** asks before it ends the stream.- Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, andthe stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.@@@ -40,6 +40,18 @@ OpenVibe.Live automatically records your streams as VODs (Video on Demand).3. **Finalize**: When the stream ends, the recording is remuxed for proper seeking, duration is probed, and a thumbnail is generated4. **Auto-cleanup**: Recordings shorter than 10 seconds are automatically deleted (test streams, accidental go-lives)++### A stream that changes shape++++A broadcaster who turns their phone, or whose connection makes WebRTC lower the resolution, sends frames of another++size partway through. The recording keeps them as they came. OpenVibe.Media's playback copy (its HLS timeline) is cut in++one part per shape, each with its own size, and the player switches between them, so a portrait part plays portrait.++Before 2026-10-26 it was squeezed into the first part's 16:9 (VOD 6017). A lowered resolution is not a new part: it is++scaled up to that part's size. A clip whose window crosses a turn shows its main shape, with bars for the rest.++++The live player does the same for viewers: the box takes the picture's own ratio (`public/js/player-shape.js`), eases++to a new one when the broadcaster turns, keeps its size when only the resolution drops, and opens on the channel's last++shape. A blurred copy of the picture fills the sides of a tall picture.++### Browser Tab Close SafetyIf you close the browser tab during a WebRTC stream, `sendBeacon` attempts to upload any remaining chunks. The server will auto-finalize when it detects the stream has ended.@@@ -1525,6 +1525,41 @@#page-channel #uptime-badge { display: none; }}++/* ── The player takes the stream's shape (2026-10-26) ──────────────────────────────++ player-shape.js sets --video-ar (the picture's width / height) on #video-container. The box stays the column's width,++ so its controls keep their room, and its height is the picture's at that width, up to --player-max-h: a wide picture++ (16:9, 21:9) fills the box exactly, a tall one (4:3 on a short screen, square, 9:16) is as tall as allowed, and a++ blurred copy of it fills the sides instead of black (.player-shape-glow, painted by player-shape.js). The cap keeps++ the channel line under the player on screen: a portrait stream used to push it below a laptop's fold. When the++ broadcaster turns their phone the box eases to the new shape; a resolution drop that keeps the shape moves nothing.++ Before the first frame the box is 16:9 (no layout shift: the phone CLS fix of 2026-10-23), or the channel's last shape.++ A phone on its side (below) and fullscreen still fill the screen. */++#page-channel .video-container {++ --player-max-h: calc(100vh - var(--navbar-h) - var(--live-tabs-h, 0px) - 64px);++ --player-max-h: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px) - 64px);++ aspect-ratio: var(--video-ar, 16 / 9);++ max-height: var(--player-max-h);++ isolation: isolate; /* the glow sits behind the picture and every overlay, above the box's black */++ transition: aspect-ratio 0.45s cubic-bezier(0.22, 1, 0.36, 1), max-height 0.3s ease, background 0.35s ease, box-shadow 0.35s ease;++}++#page-channel.has-live-tabs .video-container { --live-tabs-h: 44px; }++@media (max-width: 768px) and (orientation: portrait) {++ /* A phone held upright: up to 65% of the screen, as before, so the channel line clears the round chat button. */++ #page-channel .video-container { --player-max-h: 65vh; --player-max-h: 65dvh; }++}++#page-channel .video-container #video-element,++#page-channel .video-container #video-canvas { max-height: none; object-fit: contain; }++.player-shape-glow {++ position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none;++ opacity: 0; transform: scale(1.25); filter: blur(32px) saturate(1.25) brightness(0.5);++ transition: opacity 0.9s ease;++}++.player-shape-glow.is-on { opacity: 0.8; }++#page-channel .video-container:fullscreen { aspect-ratio: auto; max-height: none; width: 100%; height: 100%; }++@media (prefers-reduced-motion: reduce) {++ #page-channel .video-container { transition: none; }++}++/* ── A phone on its side: the stream fills the screen (2026-10-25) ───────────────Landscape, at most 540px tall, on a touch screen. Every rule above sizes the player for a portrait phone (half thescreen height at most: 195px of 390), so turning the phone made the picture smaller instead of bigger. Here the@@@ -53,6 +53,7 @@"clipJobs"],"js": [++ "/js/player-shape.js","/js/stream-player.js","/js/stream-pip.js"]nnew file mode 100644@@@ -0,0 +1,140 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Live — the live player takes the stream's own shape (owner, 2026-10-26: "ensure the live feed viewers see is++ responsive and resizes intelligently and intuitively").++++ The box was 16:9, or "vertical" when the picture was taller than wide: a 4:3, square or ultrawide stream sat in bars,++ and a portrait one on a desktop was a full-width black box whose height pushed the channel name off a laptop screen.++ Now:++ - The box's ratio is the picture's (--video-ar on #video-container, from the video's own size or a JSMPEG canvas's).++ The CSS keeps it the column's width and caps its height so the channel line below stays on screen (channel.css,++ "The player takes the stream's shape"): a wide picture fills the box exactly, a tall one is as tall as allowed.++ - A size change that keeps the ratio within SAME (WebRTC lowering 720p to 360p on a weak network) moves nothing; a++ real turn eases to the new shape (the CSS transition) instead of jumping.++ - The first paint already has the shape: the ratio this browser last saw on the channel, else the stream's thumbnail++ (hint()), until the picture says otherwise.++ - Where the box is wider than the picture (a portrait stream on a wide screen), a soft blurred copy of the picture++ fills the sides instead of black bars: a 48 px sample every 0.7 s while it plays and is on screen; none for reduced++ motion or data saver.++++ OVPlayerShape.watch(video, box, { key, canvas }) — once per player start (stream-player.js initPlayer); idempotent++ per element, and a new key (another channel) starts from that channel's remembered shape.++ OVPlayerShape.hint(box, ratio) — a ratio known before the picture (the thumbnail); ignored once the picture has one.++ ═══════════════════════════════════════════════════════════════ */++(function () {++ 'use strict';++ if (window.OVPlayerShape) return;++ const SAME = 0.03; // |ln(ratio a / ratio b)| under this is the same shape++ const MIN = 0.4, MAX = 3.2; // 9:22 .. 32:10: anything outside is a broken size, not a shape++ const STORE = 'ov_player_shape'; // { channel: ratio } (the last 40 channels)++ const DEFAULT = 16 / 9;++++ const clamp = (r) => Math.min(MAX, Math.max(MIN, r));++ const same = (a, b) => Math.abs(Math.log(a / b)) < SAME;++ function remembered(key) {++ if (!key) return null;++ try { const r = Number((JSON.parse(localStorage.getItem(STORE) || '{}') || {})[key]); return r > 0 ? clamp(r) : null; } catch { return null; }++ }++ function remember(key, ratio) {++ if (!key) return;++ try {++ const all = JSON.parse(localStorage.getItem(STORE) || '{}') || {};++ delete all[key];++ all[key] = +ratio.toFixed(4);++ const keys = Object.keys(all);++ for (const k of keys.slice(0, Math.max(0, keys.length - 40))) delete all[k];++ localStorage.setItem(STORE, JSON.stringify(all));++ } catch { /* private mode, storage full: the shape still follows the picture */ }++ }++++ function apply(box, ratio) {++ const st = box._ovShape;++ st.ratio = ratio;++ box.style.setProperty('--video-ar', ratio.toFixed(4));++ box.dataset.shape = ratio < 0.95 ? 'portrait' : ratio > 1.05 ? 'landscape' : 'square';++ box.classList.toggle('is-vertical', ratio < 0.95); // the chat sidebar's width on a desktop keys on it++ if (st.glow) st.glow.check();++ }++++ /** The blurred fill beside a picture narrower than its box. */++ function glow(box, source) {++ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;++ const saver = navigator.connection && navigator.connection.saveData;++ const c = document.createElement('canvas');++ const ctx = !reduce && !saver && c.getContext && c.getContext('2d');++ if (!ctx) return null;++ c.className = 'player-shape-glow';++ c.setAttribute('aria-hidden', 'true');++ box.insertBefore(c, box.firstChild);++ let timer = 0, visible = true, on = false;++ if (typeof IntersectionObserver === 'function') new IntersectionObserver((es) => { visible = es[0].isIntersecting; }).observe(box);++ const paint = () => {++ const src = source();++ const w = src && (src.videoWidth || src.width), h = src && (src.videoHeight || src.height);++ if (!visible || document.hidden || !w || !h || (src.readyState !== undefined && src.readyState < 2)) return;++ // Cover the box: the picture's middle, scaled up to the box's shape (the blur hides the rest).++ const bw = box.clientWidth || 1, bh = box.clientHeight || 1;++ c.width = 48; c.height = Math.max(8, Math.round(48 * bh / bw));++ const s = Math.max(c.width / w, c.height / h);++ try { ctx.drawImage(src, (c.width - w * s) / 2, (c.height - h * s) / 2, w * s, h * s); } catch { return; }++ c.classList.add('is-on');++ };++ return {++ /** On when the box is visibly wider or taller than the picture; off (and still) when the picture fills it. */++ check() {++ const st = box._ovShape;++ const bw = box.clientWidth, bh = box.clientHeight;++ const want = !!(st.ratio && bw && bh && Math.abs(Math.log(st.ratio / (bw / bh))) > 0.05);++ if (want === on) return;++ on = want;++ clearInterval(timer);++ if (on) { paint(); timer = setInterval(paint, 700); } else c.classList.remove('is-on');++ },++ };++ }++++ function watch(video, box, { key = null, canvas = null } = {}) {++ if (!video || !box) return null;++ let st = box._ovShape;++ if (!st) {++ st = box._ovShape = { ratio: null, fromPicture: false, key: null, glow: null };++ const shown = () => (canvas && canvas.style.display !== 'none' && canvas.width > 0 && !(video.videoWidth > 0) ? canvas : video);++ const update = () => {++ const src = shown();++ const w = src.videoWidth || (src === canvas ? canvas.width : 0);++ const h = src.videoHeight || (src === canvas ? canvas.height : 0);++ if (!(w > 0 && h > 0)) return;++ const r = clamp(w / h);++ st.fromPicture = true;++ if (st.ratio && same(r, st.ratio)) return;++ apply(box, r);++ remember(st.key, r);++ };++ video.addEventListener('loadedmetadata', update);++ video.addEventListener('resize', update);++ if (canvas && typeof MutationObserver === 'function') new MutationObserver(update).observe(canvas, { attributes: true, attributeFilter: ['width', 'height', 'style'] });++ st.glow = glow(box, shown);++ if (st.glow) {++ video.addEventListener('playing', () => st.glow.check());++ if (typeof ResizeObserver === 'function') new ResizeObserver(() => st.glow.check()).observe(box);++ }++ st.update = update;++ }++ // A new channel (or the first start): its remembered shape until its picture arrives. The element may still hold++ // the last channel's size, so only the new picture's own events set the shape from here.++ if (key !== st.key || !st.ratio) {++ st.key = key;++ st.fromPicture = false;++ apply(box, remembered(key) || DEFAULT);++ } else st.update();++ return st;++ }++++ function hint(box, ratio) {++ const st = box && box._ovShape;++ if (!st || st.fromPicture || !(ratio > 0)) return;++ const r = clamp(ratio);++ if (!st.ratio || !same(r, st.ratio)) apply(box, r);++ }++++ window.OVPlayerShape = { watch, hint, SAME, _same: same, _clamp: clamp };++})();@@@ -863,6 +863,10 @@ function _applyLoaderThumb(stream) {const img = new Image();img.crossOrigin = 'anonymous';img.onload = () => {++ // The thumbnail is the stream's shape: the box takes it until the picture arrives.++ if (window.OVPlayerShape && img.naturalWidth && img.naturalHeight) {++ window.OVPlayerShape.hint(document.getElementById('video-container'), img.naturalWidth / img.naturalHeight);++ }try {// Cluster the thumbnail's colours by hue and pull the TWO strongest, most// saturated clusters → a real two-tone palette to theme the loader with.@@@ -2699,21 +2703,14 @@ function setupVideoControls() {? '<i class="fa-solid fa-volume-xmark"></i>': '<i class="fa-solid fa-volume-high"></i>';-- // Detect vertical (portrait) video and add CSS class for responsive layout-- const _detectVerticalVideo = () => {-- const vid = document.getElementById('video-element');-- const container = document.getElementById('video-container');-- if (!vid || !container) return;-- const w = vid.videoWidth;-- const h = vid.videoHeight;-- if (w > 0 && h > 0) {-- container.classList.toggle('is-vertical', h > w);-- }-- };++ // The box takes the picture's shape (player-shape.js): its own ratio, eased when the broadcaster turns, the same box++ // when only the resolution drops, remembered per channel for the next first paint.const vid = document.getElementById('video-element');++ if (window.OVPlayerShape) {++ window.OVPlayerShape.watch(vid, document.getElementById('video-container'),++ { key: streamRef && streamRef.username, canvas: document.getElementById('video-canvas') });++ }if (vid) {-- vid.addEventListener('loadedmetadata', _detectVerticalVideo);-- vid.addEventListener('resize', _detectVerticalVideo);// Sync local muted state when the browser auto-mutes for autoplay policyvid.addEventListener('volumechange', () => {nnew file mode 100644@@@ -0,0 +1,149 @@++/**++ * The live player takes the stream's own shape (owner, 2026-10-26: "ensure the live feed viewers see is responsive and++ * resizes intelligently and intuitively"; public/js/player-shape.js, channel.css "The player takes the stream's shape").++ *++ * - The box's ratio is the picture's: 16:9, 4:3, square, 9:16, ultrawide, not "16:9 or vertical".++ * - A resolution drop that keeps the shape (WebRTC lowering 720p to 360p) moves nothing; a turn changes the shape.++ * - Each channel's last shape is remembered for its next first paint; another channel starts from its own.++ * - The thumbnail's shape stands in until the picture has one, never after.++ * - A JSMPEG stream (a canvas) is followed the same way.++ * - stream-player.js uses it, features.json loads it before the player, and the CSS reads --video-ar.++ *++ * node test/player-shape.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++++/** A small element: listeners, style properties, dataset, classes, children, a size. */++function element(tag, props = {}) {++ const listeners = {};++ const classes = new Set();++ const vars = {};++ return Object.assign({++ tagName: tag, children: [], dataset: {}, width: 0, height: 0, clientWidth: 0, clientHeight: 0,++ style: { display: '', setProperty(k, v) { vars[k] = v; }, getPropertyValue(k) { return vars[k]; } },++ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)) },++ addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },++ fire(t) { for (const f of listeners[t] || []) f(); },++ setAttribute() {}, insertBefore(c) { this.children.unshift(c); },++ getContext: () => ({ drawImage() {} }),++ get firstChild() { return this.children[0] || null; },++ vars,++ }, props);++}++++function load({ store = {}, reduce = false } = {}) {++ const observers = [];++ const ctx = {++ window: {}, console, Math, JSON, Number, Object, setInterval: () => 1, clearInterval() {},++ document: { hidden: false, createElement: (t) => element(t) },++ navigator: {},++ matchMedia: () => ({ matches: reduce }),++ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },++ MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb }); },++ IntersectionObserver: function () { this.observe = () => {}; },++ ResizeObserver: function () { this.observe = () => {}; },++ };++ ctx.window.window = ctx.window;++ vm.createContext(ctx);++ vm.runInContext(read('public/js/player-shape.js'), ctx);++ return { api: ctx.window.OVPlayerShape, store, mutate: () => observers.forEach((o) => o.cb()) };++}++++const ratio = (box) => Number(box.vars['--video-ar']);++const near = (a, b) => Math.abs(a - b) < 0.001;++++// ── The box follows the picture's shape ──++{++ const { api, store } = load();++ const video = element('video');++ const box = element('div', { clientWidth: 1120, clientHeight: 630 });++ api.watch(video, box, { key: 'goosely' });++ assert.ok(near(ratio(box), 16 / 9), 'before the picture: 16:9 (the first paint keeps its box)');++ assert.strictEqual(box.dataset.shape, 'landscape');++ const show = (w, h) => { video.videoWidth = w; video.videoHeight = h; video.fire('resize'); };++ for (const [w, h, shape] of [[960, 720, 'landscape'], [720, 720, 'square'], [1680, 720, 'landscape'], [720, 1280, 'portrait']]) {++ show(w, h);++ assert.ok(near(ratio(box), w / h), `${w}x${h} → ratio ${w / h} (got ${ratio(box)})`);++ assert.strictEqual(box.dataset.shape, shape, `${w}x${h} is ${shape}`);++ }++ assert.ok(box.classList.contains('is-vertical'), 'a portrait picture still marks is-vertical (the chat width keys on it)');++ // A lowered resolution keeps the shape: nothing moves (WebRTC on a weak network: 720x1280 → 540x960 → 360x640 → 358x640).++ const before = box.vars['--video-ar'];++ for (const [w, h] of [[540, 960], [360, 640], [358, 640]]) { show(w, h); assert.strictEqual(box.vars['--video-ar'], before, `${w}x${h} keeps the box`); }++ // The phone turns back: the box turns with it.++ show(1280, 720);++ assert.ok(near(ratio(box), 16 / 9));++ assert.ok(!box.classList.contains('is-vertical'));++ // A broken size is not a shape: the ratio is held to 9:22 .. 32:10.++ show(4000, 100);++ assert.ok(ratio(box) <= 3.2);++ show(1280, 720);++ // The channel's last shape is remembered.++ assert.ok(near(JSON.parse(store.ov_player_shape).goosely, 16 / 9));++ show(720, 1280);++ assert.ok(near(JSON.parse(store.ov_player_shape).goosely, 0.5625));++}++++// ── The next first paint, another channel, the thumbnail ──++{++ const { api } = load({ store: { ov_player_shape: JSON.stringify({ goosely: 0.5625, other: 1.3333 }) } });++ const video = element('video');++ const box = element('div');++ api.watch(video, box, { key: 'goosely' });++ assert.ok(near(ratio(box), 0.5625), 'a portrait streamer\'s page opens portrait');++ // The element still holds the last channel's size when the next channel starts: it is not taken for the new one.++ video.videoWidth = 720; video.videoHeight = 1280;++ api.watch(video, box, { key: 'newcomer' });++ assert.ok(near(ratio(box), 16 / 9), 'a channel never seen opens 16:9');++ api.hint(box, 4 / 3);++ assert.ok(near(ratio(box), 4 / 3), 'the thumbnail stands in before the picture');++ video.videoWidth = 1920; video.videoHeight = 1080; video.fire('loadedmetadata');++ assert.ok(near(ratio(box), 16 / 9), 'the picture wins');++ api.hint(box, 9 / 16);++ assert.ok(near(ratio(box), 16 / 9), 'a late thumbnail never overrides the picture');++ api.watch(video, box, { key: 'other' });++ assert.ok(near(ratio(box), 1.3333), 'switching channels starts from that channel\'s shape');++}++++// ── A JSMPEG stream: the canvas's size ──++{++ const { api, mutate } = load();++ const video = element('video');++ const canvas = element('canvas');++ const box = element('div');++ api.watch(video, box, { key: 'pi', canvas });++ canvas.width = 640; canvas.height = 480; mutate();++ assert.ok(near(ratio(box), 4 / 3), 'the canvas drives the shape while it is the picture');++}++++// ── The glow is not made for reduced motion; the page wiring ──++{++ const { api } = load({ reduce: true });++ const box = element('div');++ api.watch(element('video'), box, { key: 'x' });++ assert.strictEqual(box.children.length, 0, 'no glow canvas with reduced motion');++ const withGlow = element('div');++ load().api.watch(element('video'), withGlow, { key: 'y' });++ assert.strictEqual(withGlow.children[0] && withGlow.children[0].className, 'player-shape-glow');++++ const player = read('public/js/stream-player.js');++ assert.match(player, /OVPlayerShape\.watch\(vid, document\.getElementById\('video-container'\)/);++ assert.match(player, /OVPlayerShape\.hint\(document\.getElementById\('video-container'\), img\.naturalWidth \/ img\.naturalHeight\)/);++ assert.ok(!/_detectVerticalVideo/.test(player), 'the old two-shape rule is gone');++ const features = JSON.parse(read('public/features.json')).features;++ const js = features.player.js;++ assert.ok(js.indexOf('/js/player-shape.js') >= 0 && js.indexOf('/js/player-shape.js') < js.indexOf('/js/stream-player.js'), 'loaded before the player');++ const css = read('public/css/features/channel.css');++ assert.match(css, /#page-channel \.video-container \{[^}]*aspect-ratio: var\(--video-ar, 16 \/ 9\);[^}]*max-height: var\(--player-max-h\)/);++ // The phone on its side still fills the screen: its block comes after the shape block.++ assert.ok(css.indexOf("The player takes the stream's shape") < css.indexOf('A phone on its side: the stream fills the screen'));++}++++console.log('✅ player shape: any ratio, resolution drops ignored, turns followed, remembered per channel, thumbnail hint, canvas, wiring');
@@ -43,6 +43,8 @@ You don't need any of the following:
@@ -43,6 +43,8 @@ You don't need any of the following:
only.
only.
- A phone sends H.264, which its hardware encodes, so the stream uses less battery and keeps its frame rate. When the
- A phone sends H.264, which its hardware encodes, so the stream uses less battery and keeps its frame rate. When the
network or the phone gets busy, the picture gets softer before it gets choppy.
network or the phone gets busy, the picture gets softer before it gets choppy.
+- **Turn the phone whenever you like.** Viewers' players take your picture's shape, upright or sideways, and ease from
+ one to the other when you turn. The recording keeps each part in its own shape too.
- **End** asks before it ends the stream.
- **End** asks before it ends the stream.
- Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, and
- Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, and
the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.
the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.
@@ -40,6 +40,18 @@ OpenVibe.Live automatically records your streams as VODs (Video on Demand).
@@ -40,6 +40,18 @@ OpenVibe.Live automatically records your streams as VODs (Video on Demand).
3. **Finalize**: When the stream ends, the recording is remuxed for proper seeking, duration is probed, and a thumbnail is generated
3. **Finalize**: When the stream ends, the recording is remuxed for proper seeking, duration is probed, and a thumbnail is generated
4. **Auto-cleanup**: Recordings shorter than 10 seconds are automatically deleted (test streams, accidental go-lives)
4. **Auto-cleanup**: Recordings shorter than 10 seconds are automatically deleted (test streams, accidental go-lives)
+### A stream that changes shape
+
+A broadcaster who turns their phone, or whose connection makes WebRTC lower the resolution, sends frames of another
+size partway through. The recording keeps them as they came. OpenVibe.Media's playback copy (its HLS timeline) is cut in
+one part per shape, each with its own size, and the player switches between them, so a portrait part plays portrait.
+Before 2026-10-26 it was squeezed into the first part's 16:9 (VOD 6017). A lowered resolution is not a new part: it is
+scaled up to that part's size. A clip whose window crosses a turn shows its main shape, with bars for the rest.
+
+The live player does the same for viewers: the box takes the picture's own ratio (`public/js/player-shape.js`), eases
+to a new one when the broadcaster turns, keeps its size when only the resolution drops, and opens on the channel's last
+shape. A blurred copy of the picture fills the sides of a tall picture.
+
### Browser Tab Close Safety
### Browser Tab Close Safety
If you close the browser tab during a WebRTC stream, `sendBeacon` attempts to upload any remaining chunks. The server will auto-finalize when it detects the stream has ended.
If you close the browser tab during a WebRTC stream, `sendBeacon` attempts to upload any remaining chunks. The server will auto-finalize when it detects the stream has ended.
@@ -1525,6 +1525,41 @@
@@ -1525,6 +1525,41 @@
#page-channel #uptime-badge { display: none; }
#page-channel #uptime-badge { display: none; }
}
}
+/* ── The player takes the stream's shape (2026-10-26) ──────────────────────────────
+ player-shape.js sets --video-ar (the picture's width / height) on #video-container. The box stays the column's width,
+ so its controls keep their room, and its height is the picture's at that width, up to --player-max-h: a wide picture
+ (16:9, 21:9) fills the box exactly, a tall one (4:3 on a short screen, square, 9:16) is as tall as allowed, and a
+ blurred copy of it fills the sides instead of black (.player-shape-glow, painted by player-shape.js). The cap keeps
+ the channel line under the player on screen: a portrait stream used to push it below a laptop's fold. When the
+ broadcaster turns their phone the box eases to the new shape; a resolution drop that keeps the shape moves nothing.
+ Before the first frame the box is 16:9 (no layout shift: the phone CLS fix of 2026-10-23), or the channel's last shape.
+ A phone on its side (below) and fullscreen still fill the screen. */
+#page-channel .video-container {
+ --player-max-h: calc(100vh - var(--navbar-h) - var(--live-tabs-h, 0px) - 64px);
+ --player-max-h: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px) - 64px);
+ aspect-ratio: var(--video-ar, 16 / 9);
+ max-height: var(--player-max-h);
+ isolation: isolate; /* the glow sits behind the picture and every overlay, above the box's black */
+ transition: aspect-ratio 0.45s cubic-bezier(0.22, 1, 0.36, 1), max-height 0.3s ease, background 0.35s ease, box-shadow 0.35s ease;
+}
+#page-channel.has-live-tabs .video-container { --live-tabs-h: 44px; }
+@media (max-width: 768px) and (orientation: portrait) {
+ /* A phone held upright: up to 65% of the screen, as before, so the channel line clears the round chat button. */
+ #page-channel .video-container { --player-max-h: 65vh; --player-max-h: 65dvh; }
+}
+#page-channel .video-container #video-element,
+#page-channel .video-container #video-canvas { max-height: none; object-fit: contain; }
+.player-shape-glow {
+ position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none;
+ opacity: 0; transform: scale(1.25); filter: blur(32px) saturate(1.25) brightness(0.5);
+ transition: opacity 0.9s ease;
+}
+.player-shape-glow.is-on { opacity: 0.8; }
+#page-channel .video-container:fullscreen { aspect-ratio: auto; max-height: none; width: 100%; height: 100%; }
+@media (prefers-reduced-motion: reduce) {
+ #page-channel .video-container { transition: none; }
+}
+
/* ── A phone on its side: the stream fills the screen (2026-10-25) ───────────────
/* ── A phone on its side: the stream fills the screen (2026-10-25) ───────────────
Landscape, at most 540px tall, on a touch screen. Every rule above sizes the player for a portrait phone (half the
Landscape, at most 540px tall, on a touch screen. Every rule above sizes the player for a portrait phone (half the
screen height at most: 195px of 390), so turning the phone made the picture smaller instead of bigger. Here the
screen height at most: 195px of 390), so turning the phone made the picture smaller instead of bigger. Here the
@@ -53,6 +53,7 @@
@@ -53,6 +53,7 @@
"clipJobs"
"clipJobs"
],
],
"js": [
"js": [
+ "/js/player-shape.js",
"/js/stream-player.js",
"/js/stream-player.js",
"/js/stream-pip.js"
"/js/stream-pip.js"
]
]
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Live — the live player takes the stream's own shape (owner, 2026-10-26: "ensure the live feed viewers see is
+ responsive and resizes intelligently and intuitively").
+
+ The box was 16:9, or "vertical" when the picture was taller than wide: a 4:3, square or ultrawide stream sat in bars,
+ and a portrait one on a desktop was a full-width black box whose height pushed the channel name off a laptop screen.
+ Now:
+ - The box's ratio is the picture's (--video-ar on #video-container, from the video's own size or a JSMPEG canvas's).
+ The CSS keeps it the column's width and caps its height so the channel line below stays on screen (channel.css,
+ "The player takes the stream's shape"): a wide picture fills the box exactly, a tall one is as tall as allowed.
+ - A size change that keeps the ratio within SAME (WebRTC lowering 720p to 360p on a weak network) moves nothing; a
+ real turn eases to the new shape (the CSS transition) instead of jumping.
+ - The first paint already has the shape: the ratio this browser last saw on the channel, else the stream's thumbnail
+ (hint()), until the picture says otherwise.
+ - Where the box is wider than the picture (a portrait stream on a wide screen), a soft blurred copy of the picture
+ fills the sides instead of black bars: a 48 px sample every 0.7 s while it plays and is on screen; none for reduced
+ motion or data saver.
+
+ OVPlayerShape.watch(video, box, { key, canvas }) — once per player start (stream-player.js initPlayer); idempotent
+ per element, and a new key (another channel) starts from that channel's remembered shape.
+ OVPlayerShape.hint(box, ratio) — a ratio known before the picture (the thumbnail); ignored once the picture has one.
+ ═══════════════════════════════════════════════════════════════ */
+(function () {
+ 'use strict';
+ if (window.OVPlayerShape) return;
+ const SAME = 0.03; // |ln(ratio a / ratio b)| under this is the same shape
+ const MIN = 0.4, MAX = 3.2; // 9:22 .. 32:10: anything outside is a broken size, not a shape
+ const STORE = 'ov_player_shape'; // { channel: ratio } (the last 40 channels)
+ const DEFAULT = 16 / 9;
+
+ const clamp = (r) => Math.min(MAX, Math.max(MIN, r));
+ const same = (a, b) => Math.abs(Math.log(a / b)) < SAME;
+ function remembered(key) {
+ if (!key) return null;
+ try { const r = Number((JSON.parse(localStorage.getItem(STORE) || '{}') || {})[key]); return r > 0 ? clamp(r) : null; } catch { return null; }
+ }
+ function remember(key, ratio) {
+ if (!key) return;
+ try {
+ const all = JSON.parse(localStorage.getItem(STORE) || '{}') || {};
+ delete all[key];
+ all[key] = +ratio.toFixed(4);
+ const keys = Object.keys(all);
+ for (const k of keys.slice(0, Math.max(0, keys.length - 40))) delete all[k];
+ localStorage.setItem(STORE, JSON.stringify(all));
+ } catch { /* private mode, storage full: the shape still follows the picture */ }
+ }
+
+ function apply(box, ratio) {
+ const st = box._ovShape;
+ st.ratio = ratio;
+ box.style.setProperty('--video-ar', ratio.toFixed(4));
+ box.dataset.shape = ratio < 0.95 ? 'portrait' : ratio > 1.05 ? 'landscape' : 'square';
+ box.classList.toggle('is-vertical', ratio < 0.95); // the chat sidebar's width on a desktop keys on it
+ if (st.glow) st.glow.check();
+ }
+
+ /** The blurred fill beside a picture narrower than its box. */
+ function glow(box, source) {
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const saver = navigator.connection && navigator.connection.saveData;
+ const c = document.createElement('canvas');
+ const ctx = !reduce && !saver && c.getContext && c.getContext('2d');
+ if (!ctx) return null;
+ c.className = 'player-shape-glow';
+ c.setAttribute('aria-hidden', 'true');
+ box.insertBefore(c, box.firstChild);
+ let timer = 0, visible = true, on = false;
+ if (typeof IntersectionObserver === 'function') new IntersectionObserver((es) => { visible = es[0].isIntersecting; }).observe(box);
+ const paint = () => {
+ const src = source();
+ const w = src && (src.videoWidth || src.width), h = src && (src.videoHeight || src.height);
+ if (!visible || document.hidden || !w || !h || (src.readyState !== undefined && src.readyState < 2)) return;
+ // Cover the box: the picture's middle, scaled up to the box's shape (the blur hides the rest).
+ const bw = box.clientWidth || 1, bh = box.clientHeight || 1;
+ c.width = 48; c.height = Math.max(8, Math.round(48 * bh / bw));
+ const s = Math.max(c.width / w, c.height / h);
+ try { ctx.drawImage(src, (c.width - w * s) / 2, (c.height - h * s) / 2, w * s, h * s); } catch { return; }
+ c.classList.add('is-on');
+ };
+ return {
+ /** On when the box is visibly wider or taller than the picture; off (and still) when the picture fills it. */
+ check() {
+ const st = box._ovShape;
+ const bw = box.clientWidth, bh = box.clientHeight;
+ const want = !!(st.ratio && bw && bh && Math.abs(Math.log(st.ratio / (bw / bh))) > 0.05);
+ if (want === on) return;
+ on = want;
+ clearInterval(timer);
+ if (on) { paint(); timer = setInterval(paint, 700); } else c.classList.remove('is-on');
+ },
+ };
+ }
+
+ function watch(video, box, { key = null, canvas = null } = {}) {
+ if (!video || !box) return null;
+ let st = box._ovShape;
+ if (!st) {
+ st = box._ovShape = { ratio: null, fromPicture: false, key: null, glow: null };
+ const shown = () => (canvas && canvas.style.display !== 'none' && canvas.width > 0 && !(video.videoWidth > 0) ? canvas : video);
+ const update = () => {
+ const src = shown();
+ const w = src.videoWidth || (src === canvas ? canvas.width : 0);
+ const h = src.videoHeight || (src === canvas ? canvas.height : 0);
+ if (!(w > 0 && h > 0)) return;
+ const r = clamp(w / h);
+ st.fromPicture = true;
+ if (st.ratio && same(r, st.ratio)) return;
+ apply(box, r);
+ remember(st.key, r);
+ };
+ video.addEventListener('loadedmetadata', update);
+ video.addEventListener('resize', update);
+ if (canvas && typeof MutationObserver === 'function') new MutationObserver(update).observe(canvas, { attributes: true, attributeFilter: ['width', 'height', 'style'] });
+ st.glow = glow(box, shown);
+ if (st.glow) {
+ video.addEventListener('playing', () => st.glow.check());
+ if (typeof ResizeObserver === 'function') new ResizeObserver(() => st.glow.check()).observe(box);
+ }
+ st.update = update;
+ }
+ // A new channel (or the first start): its remembered shape until its picture arrives. The element may still hold
+ // the last channel's size, so only the new picture's own events set the shape from here.
+ if (key !== st.key || !st.ratio) {
+ st.key = key;
+ st.fromPicture = false;
+ apply(box, remembered(key) || DEFAULT);
+ } else st.update();
+ return st;
+ }
+
+ function hint(box, ratio) {
+ const st = box && box._ovShape;
+ if (!st || st.fromPicture || !(ratio > 0)) return;
+ const r = clamp(ratio);
+ if (!st.ratio || !same(r, st.ratio)) apply(box, r);
+ }
+
+ window.OVPlayerShape = { watch, hint, SAME, _same: same, _clamp: clamp };
+})();
@@ -863,6 +863,10 @@ function _applyLoaderThumb(stream) {
@@ -863,6 +863,10 @@ function _applyLoaderThumb(stream) {
const img = new Image();
const img = new Image();
img.crossOrigin = 'anonymous';
img.crossOrigin = 'anonymous';
img.onload = () => {
img.onload = () => {
+ // The thumbnail is the stream's shape: the box takes it until the picture arrives.
+ if (window.OVPlayerShape && img.naturalWidth && img.naturalHeight) {
+ window.OVPlayerShape.hint(document.getElementById('video-container'), img.naturalWidth / img.naturalHeight);
+ }
try {
try {
// Cluster the thumbnail's colours by hue and pull the TWO strongest, most
// Cluster the thumbnail's colours by hue and pull the TWO strongest, most
// saturated clusters → a real two-tone palette to theme the loader with.
// saturated clusters → a real two-tone palette to theme the loader with.
@@ -2699,21 +2703,14 @@ function setupVideoControls() {
@@ -2699,21 +2703,14 @@ function setupVideoControls() {
? '<i class="fa-solid fa-volume-xmark"></i>'
? '<i class="fa-solid fa-volume-xmark"></i>'
: '<i class="fa-solid fa-volume-high"></i>';
: '<i class="fa-solid fa-volume-high"></i>';
- // Detect vertical (portrait) video and add CSS class for responsive layout
- const _detectVerticalVideo = () => {
- const vid = document.getElementById('video-element');
- const container = document.getElementById('video-container');
- if (!vid || !container) return;
- const w = vid.videoWidth;
- const h = vid.videoHeight;
- if (w > 0 && h > 0) {
- container.classList.toggle('is-vertical', h > w);
- }
- };
+ // The box takes the picture's shape (player-shape.js): its own ratio, eased when the broadcaster turns, the same box
+ // when only the resolution drops, remembered per channel for the next first paint.
const vid = document.getElementById('video-element');
const vid = document.getElementById('video-element');
+ if (window.OVPlayerShape) {
+ window.OVPlayerShape.watch(vid, document.getElementById('video-container'),
+ { key: streamRef && streamRef.username, canvas: document.getElementById('video-canvas') });
+ }
if (vid) {
if (vid) {
- vid.addEventListener('loadedmetadata', _detectVerticalVideo);
- vid.addEventListener('resize', _detectVerticalVideo);
// Sync local muted state when the browser auto-mutes for autoplay policy
// Sync local muted state when the browser auto-mutes for autoplay policy
vid.addEventListener('volumechange', () => {
vid.addEventListener('volumechange', () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+/**
+ * The live player takes the stream's own shape (owner, 2026-10-26: "ensure the live feed viewers see is responsive and
+ * resizes intelligently and intuitively"; public/js/player-shape.js, channel.css "The player takes the stream's shape").
+ *
+ * - The box's ratio is the picture's: 16:9, 4:3, square, 9:16, ultrawide, not "16:9 or vertical".
+ * - A resolution drop that keeps the shape (WebRTC lowering 720p to 360p) moves nothing; a turn changes the shape.
+ * - Each channel's last shape is remembered for its next first paint; another channel starts from its own.
+ * - The thumbnail's shape stands in until the picture has one, never after.
+ * - A JSMPEG stream (a canvas) is followed the same way.
+ * - stream-player.js uses it, features.json loads it before the player, and the CSS reads --video-ar.
+ *
+ * node test/player-shape.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+
+/** A small element: listeners, style properties, dataset, classes, children, a size. */
+function element(tag, props = {}) {
+ const listeners = {};
+ const classes = new Set();
+ const vars = {};
+ return Object.assign({
+ tagName: tag, children: [], dataset: {}, width: 0, height: 0, clientWidth: 0, clientHeight: 0,
+ style: { display: '', setProperty(k, v) { vars[k] = v; }, getPropertyValue(k) { return vars[k]; } },
+ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)) },
+ addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },
+ fire(t) { for (const f of listeners[t] || []) f(); },
+ setAttribute() {}, insertBefore(c) { this.children.unshift(c); },
+ getContext: () => ({ drawImage() {} }),
+ get firstChild() { return this.children[0] || null; },
+ vars,
+ }, props);
+}
+
+function load({ store = {}, reduce = false } = {}) {
+ const observers = [];
+ const ctx = {
+ window: {}, console, Math, JSON, Number, Object, setInterval: () => 1, clearInterval() {},
+ document: { hidden: false, createElement: (t) => element(t) },
+ navigator: {},
+ matchMedia: () => ({ matches: reduce }),
+ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
+ MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb }); },
+ IntersectionObserver: function () { this.observe = () => {}; },
+ ResizeObserver: function () { this.observe = () => {}; },
+ };
+ ctx.window.window = ctx.window;
+ vm.createContext(ctx);
+ vm.runInContext(read('public/js/player-shape.js'), ctx);
+ return { api: ctx.window.OVPlayerShape, store, mutate: () => observers.forEach((o) => o.cb()) };
+}
+
+const ratio = (box) => Number(box.vars['--video-ar']);
+const near = (a, b) => Math.abs(a - b) < 0.001;
+
+// ── The box follows the picture's shape ──
+{
+ const { api, store } = load();
+ const video = element('video');
+ const box = element('div', { clientWidth: 1120, clientHeight: 630 });
+ api.watch(video, box, { key: 'goosely' });
+ assert.ok(near(ratio(box), 16 / 9), 'before the picture: 16:9 (the first paint keeps its box)');
+ assert.strictEqual(box.dataset.shape, 'landscape');
+ const show = (w, h) => { video.videoWidth = w; video.videoHeight = h; video.fire('resize'); };
+ for (const [w, h, shape] of [[960, 720, 'landscape'], [720, 720, 'square'], [1680, 720, 'landscape'], [720, 1280, 'portrait']]) {
+ show(w, h);
+ assert.ok(near(ratio(box), w / h), `${w}x${h} → ratio ${w / h} (got ${ratio(box)})`);
+ assert.strictEqual(box.dataset.shape, shape, `${w}x${h} is ${shape}`);
+ }
+ assert.ok(box.classList.contains('is-vertical'), 'a portrait picture still marks is-vertical (the chat width keys on it)');
+ // A lowered resolution keeps the shape: nothing moves (WebRTC on a weak network: 720x1280 → 540x960 → 360x640 → 358x640).
+ const before = box.vars['--video-ar'];
+ for (const [w, h] of [[540, 960], [360, 640], [358, 640]]) { show(w, h); assert.strictEqual(box.vars['--video-ar'], before, `${w}x${h} keeps the box`); }
+ // The phone turns back: the box turns with it.
+ show(1280, 720);
+ assert.ok(near(ratio(box), 16 / 9));
+ assert.ok(!box.classList.contains('is-vertical'));
+ // A broken size is not a shape: the ratio is held to 9:22 .. 32:10.
+ show(4000, 100);
+ assert.ok(ratio(box) <= 3.2);
+ show(1280, 720);
+ // The channel's last shape is remembered.
+ assert.ok(near(JSON.parse(store.ov_player_shape).goosely, 16 / 9));
+ show(720, 1280);
+ assert.ok(near(JSON.parse(store.ov_player_shape).goosely, 0.5625));
+}
+
+// ── The next first paint, another channel, the thumbnail ──
+{
+ const { api } = load({ store: { ov_player_shape: JSON.stringify({ goosely: 0.5625, other: 1.3333 }) } });
+ const video = element('video');
+ const box = element('div');
+ api.watch(video, box, { key: 'goosely' });
+ assert.ok(near(ratio(box), 0.5625), 'a portrait streamer\'s page opens portrait');
+ // The element still holds the last channel's size when the next channel starts: it is not taken for the new one.
+ video.videoWidth = 720; video.videoHeight = 1280;
+ api.watch(video, box, { key: 'newcomer' });
+ assert.ok(near(ratio(box), 16 / 9), 'a channel never seen opens 16:9');
+ api.hint(box, 4 / 3);
+ assert.ok(near(ratio(box), 4 / 3), 'the thumbnail stands in before the picture');
+ video.videoWidth = 1920; video.videoHeight = 1080; video.fire('loadedmetadata');
+ assert.ok(near(ratio(box), 16 / 9), 'the picture wins');
+ api.hint(box, 9 / 16);
+ assert.ok(near(ratio(box), 16 / 9), 'a late thumbnail never overrides the picture');
+ api.watch(video, box, { key: 'other' });
+ assert.ok(near(ratio(box), 1.3333), 'switching channels starts from that channel\'s shape');
+}
+
+// ── A JSMPEG stream: the canvas's size ──
+{
+ const { api, mutate } = load();
+ const video = element('video');
+ const canvas = element('canvas');
+ const box = element('div');
+ api.watch(video, box, { key: 'pi', canvas });
+ canvas.width = 640; canvas.height = 480; mutate();
+ assert.ok(near(ratio(box), 4 / 3), 'the canvas drives the shape while it is the picture');
+}
+
+// ── The glow is not made for reduced motion; the page wiring ──
+{
+ const { api } = load({ reduce: true });
+ const box = element('div');
+ api.watch(element('video'), box, { key: 'x' });
+ assert.strictEqual(box.children.length, 0, 'no glow canvas with reduced motion');
+ const withGlow = element('div');
+ load().api.watch(element('video'), withGlow, { key: 'y' });
+ assert.strictEqual(withGlow.children[0] && withGlow.children[0].className, 'player-shape-glow');
+
+ const player = read('public/js/stream-player.js');
+ assert.match(player, /OVPlayerShape\.watch\(vid, document\.getElementById\('video-container'\)/);
+ assert.match(player, /OVPlayerShape\.hint\(document\.getElementById\('video-container'\), img\.naturalWidth \/ img\.naturalHeight\)/);
+ assert.ok(!/_detectVerticalVideo/.test(player), 'the old two-shape rule is gone');
+ const features = JSON.parse(read('public/features.json')).features;
+ const js = features.player.js;
+ assert.ok(js.indexOf('/js/player-shape.js') >= 0 && js.indexOf('/js/player-shape.js') < js.indexOf('/js/stream-player.js'), 'loaded before the player');
+ const css = read('public/css/features/channel.css');
+ assert.match(css, /#page-channel \.video-container \{[^}]*aspect-ratio: var\(--video-ar, 16 \/ 9\);[^}]*max-height: var\(--player-max-h\)/);
+ // The phone on its side still fills the screen: its block comes after the shape block.
+ assert.ok(css.indexOf("The player takes the stream's shape") < css.indexOf('A phone on its side: the stream fills the screen'));
+}
+
+console.log('✅ player shape: any ratio, resolution drops ignored, turns followed, remembered per channel, thumbnail hint, canvas, wiring');