openvibers/openvibe.mediahub Commits

Watch page: the player takes the picture's shape once it plays, and eases when a recording turns (#137)

OP OpenVibers committed 18 days ago · 8b9ee2deec · 3 files, +71 −1

An upright recording played in a 16:9 box, and since Media#180 a recording that turns mid-stream switches shape between
its parts. video.js followShape sets --ovv-ar from the video's own size once playback starts (before that the box stays
16:9, so loading the page shifts nothing below it), ignores a size change that keeps the ratio within 3% (a lowered
resolution), and video.css eases the box between shapes, its height capped so the title stays on screen. A phone held
sideways still fills the screen.

Checked: test/video-shape.test.js, video-pages, video-clip, perf-budget.

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 8418db1..18542e9 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -99,7 +99,10 @@
/* The player clips itself to the corners, so the wrapper can let the ambient glow (shared player.js) spill around it. */
.ovv-player-wrap > .ov-player, .ovv-player-wrap > video { border-radius: inherit; overflow: hidden; }
.ovv-player-wrap.ov-player-host { box-shadow: none; border-color: transparent; }
--.ovv-player { display: block; width: 100%; aspect-ratio: 16 / 9; background: #000; }
++/* The picture's shape once it plays (video.js followShape: --ovv-ar), eased when a recording turns mid-stream; 16:9
++ before that. The height is capped so the title under the player stays on screen. */
++.ovv-player { display: block; width: 100%; aspect-ratio: var(--ovv-ar, 16 / 9); max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); background: #000; object-fit: contain; transition: aspect-ratio .45s cubic-bezier(.22, 1, .36, 1); }
++@media (prefers-reduced-motion: reduce) { .ovv-player { transition: none; } }
.ovv-title { margin: 1rem 0 .6rem; font-size: clamp(1.3rem, 2.4vw, 1.75rem); line-height: 1.25; }
.ovv-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 14px; padding-bottom: 16px; border-bottom: 1px solid var(--ovv-line); }
.ovv-avatar { display: grid; place-items: center; flex: none; width: 42px; height: 42px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-light, #60a5fa), var(--accent-strong, #2563eb)); color: #fff !important; font-weight: 800; text-decoration: none; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 385ca5f..39d6967 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -35,6 +35,26 @@
});
}
++ // The player takes the picture's shape once it plays (2026-10-26): an upright recording opens into an upright box,
++ // and one that turns mid-stream (OpenVibe.Media cuts it in one part per shape) eases from one to the other. A size
++ // change that keeps the ratio within 3% (a lowered resolution) moves nothing. Until playback starts the box stays
++ // 16:9, so loading the page shifts nothing below it; video.css caps the height so the title stays on screen.
++ function followShape(video) {
++ let current = 0;
++ let on = false;
++ const update = function () {
++ const w = video.videoWidth;
++ const h = video.videoHeight;
++ if (!on || !(w > 0 && h > 0)) return;
++ const r = Math.min(3.2, Math.max(0.4, w / h));
++ if (current && Math.abs(Math.log(r / current)) < 0.03) return;
++ current = r;
++ video.style.setProperty('--ovv-ar', r.toFixed(4));
++ };
++ video.addEventListener('playing', function () { on = true; update(); });
++ video.addEventListener('resize', update);
++ }
++
function attachSource(video) {
const hlsUrl = video.getAttribute('data-ovv-hls');
const fileUrl = video.getAttribute('src');
@@@ -112,6 +132,7 @@
});
}
++ followShape(video);
const t = startSeconds();
attachSource(video).then(function (source) {
video.setAttribute('data-ovv-source', source);
ddiff --git a/test/video-shape.test.js b/test/video-shape.test.js
nnew file mode 100644
iindex 0000000..fede33e
---- /dev/null
++++ b/test/video-shape.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++// The watch page's player takes the picture's shape once it plays (public/js/video.js followShape, public/css/video.css):
++// an upright recording opens into an upright box, one that turns mid-stream (OpenVibe.Media cuts one part per shape since
++// 2026-10-26; VOD 6017 played its portrait part squeezed into 16:9) eases between its parts' shapes, a lowered resolution
++// moves nothing, and nothing changes before playback starts (loading the page shifts nothing below the player).
++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', 'video.js'), 'utf8');
++const m = /function followShape\(video\) \{[\s\S]*?\n {4}\}\n/.exec(src);
++assert.ok(m, 'followShape() is in video.js');
++const ctx = { Math };
++vm.createContext(ctx);
++vm.runInContext(`${m[0]}; this.followShape = followShape;`, ctx);
++
++const listeners = {};
++const vars = {};
++const video = {
++ videoWidth: 0, videoHeight: 0,
++ style: { setProperty: (k, v) => { vars[k] = v; } },
++ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
++};
++const fire = (t) => (listeners[t] || []).forEach((f) => f());
++const show = (w, h) => { video.videoWidth = w; video.videoHeight = h; fire('resize'); };
++ctx.followShape(video);
++
++show(1280, 720);
++assert.strictEqual(vars['--ovv-ar'], undefined, 'before playback the box stays 16:9 (no shift on load)');
++fire('playing');
++assert.strictEqual(vars['--ovv-ar'], '1.7778');
++show(720, 1280);
++assert.strictEqual(vars['--ovv-ar'], '0.5625', 'the recording turns: the box turns with it');
++for (const [w, h] of [[540, 960], [360, 640], [358, 640]]) { show(w, h); assert.strictEqual(vars['--ovv-ar'], '0.5625', `${w}x${h} keeps the box`); }
++show(960, 720);
++assert.strictEqual(vars['--ovv-ar'], '1.3333');
++show(8000, 100);
++assert.strictEqual(vars['--ovv-ar'], '3.2000', 'a broken size is held to 32:10');
++
++assert.match(src, /followShape\(video\);\n\s+const t = startSeconds\(\);/, 'the watch page follows its video');
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.css'), 'utf8');
++assert.match(css, /\.ovv-player \{[^}]*aspect-ratio: var\(--ovv-ar, 16 \/ 9\);[^}]*max-height: calc\(100dvh - 150px\)/);
++// A phone held sideways still fills the screen (its rule comes later and sets aspect-ratio: auto).
++assert.ok(css.indexOf('aspect-ratio: var(--ovv-ar') < css.indexOf('video.ovv-player { aspect-ratio: auto; height: 100vh;'));
++console.log('✅ watch page player: the picture\'s shape once it plays, turns followed, resolution drops ignored');