openvibers/openvibe.mediahub Commits
Watch page: the player takes the picture's shape once it plays, and eases when a recording turns (#137)
Changes
@@@ -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; }@@@ -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);nnew file mode 100644@@@ -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');
@@ -99,7 +99,10 @@
@@ -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. */
/* 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, .ovv-player-wrap > video { border-radius: inherit; overflow: hidden; }
.ovv-player-wrap.ov-player-host { box-shadow: none; border-color: transparent; }
.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-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-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; }
.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; }
@@ -35,6 +35,26 @@
@@ -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) {
function attachSource(video) {
const hlsUrl = video.getAttribute('data-ovv-hls');
const hlsUrl = video.getAttribute('data-ovv-hls');
const fileUrl = video.getAttribute('src');
const fileUrl = video.getAttribute('src');
@@ -112,6 +132,7 @@
@@ -112,6 +132,7 @@
});
});
}
}
+ followShape(video);
const t = startSeconds();
const t = startSeconds();
attachSource(video).then(function (source) {
attachSource(video).then(function (source) {
video.setAttribute('data-ovv-source', source);
video.setAttribute('data-ovv-source', source);
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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');