openvibers/openvibe.live Commits

The live player takes the stream's own shape: any ratio, eased on a turn, steady through resolution drops (#372)

OP OpenVibers committed 18 days ago · 8253718414 · 7 files, +349 −13

The box was 16:9 or "vertical": a 4:3, square or ultrawide stream sat in bars (a square one was 219 px tall on a
phone), and a portrait stream on a desktop was a full-width black box that pushed the channel line below a laptop's
fold. public/js/player-shape.js (loaded before stream-player.js) now:

- sets --video-ar on #video-container from the picture's own size (or a JSMPEG canvas's); channel.css keeps the box
  the column's width and its height the picture's, capped so the channel line stays on screen (65dvh on an upright
  phone, as before)
- ignores a size change that keeps the ratio within 3% (WebRTC lowering 720p to 360p) and eases on a real turn
- opens on the channel's last shape (localStorage), else the thumbnail's, until the picture says otherwise
- fills the sides of a tall picture with a blurred copy of it instead of black (none for reduced motion or data saver)

A phone on its side and fullscreen still fill the screen. Docs: go-live-in-your-browser, vods-and-clips.

Checked: test/player-shape.test.js, the 65 tests the change reaches, and the real channel CSS and markup in Chrome at
five viewports and five shapes (desktop 4:3 picture 842x632 -> 1040x780; laptop portrait keeps the channel line on
screen; phone square 219 -> 390 px tall).

Changes

ddiff --git a/docs/go-live-in-your-browser.md b/docs/go-live-in-your-browser.md
iindex 32be65c6..5dad6902 100644
---- a/docs/go-live-in-your-browser.md
++++ b/docs/go-live-in-your-browser.md
@@@ -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 the
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.
- 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.
ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 3b2598f0..7cd16756 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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
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
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.
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 72b6a079..615179d4 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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 the
screen height at most: 195px of 390), so turning the phone made the picture smaller instead of bigger. Here the
ddiff --git a/public/features.json b/public/features.json
iindex dbfc58b2..b05b8158 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -53,6 +53,7 @@
"clipJobs"
],
"js": [
++ "/js/player-shape.js",
"/js/stream-player.js",
"/js/stream-pip.js"
]
ddiff --git a/public/js/player-shape.js b/public/js/player-shape.js
nnew file mode 100644
iindex 00000000..c47d883b
---- /dev/null
++++ b/public/js/player-shape.js
@@@ -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 };
++})();
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 7438966b..c56f8200 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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 policy
vid.addEventListener('volumechange', () => {
ddiff --git a/test/player-shape.test.js b/test/player-shape.test.js
nnew file mode 100644
iindex 00000000..7bcc3857
---- /dev/null
++++ b/test/player-shape.test.js
@@@ -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');