openvibers/openvibe.mediahub Commits
Video on phones: a recording opens in its own shape, the title and buttons on the first screen, the buttons in one row that fits (#163)
Changes
@@@ -115,6 +115,9 @@ body:has(> main.ovv-page) .openvibe-navbar.ovnav-top { background-color: var(--bbefore 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; } }++/* An upright recording on a phone or tablet held upright: at most 70% of the screen, so its title and buttons show under++ it without scrolling. */++@media (max-width: 1000px) and (orientation: portrait) { .ovv-player { max-height: 70vh; max-height: 70svh; } }.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; }@@@ -327,7 +330,10 @@ a.ovv-channel-name:hover { text-decoration: underline; }.ovv-head { margin-top: 14px; }.ovv-head .ovv-title { margin: 0 0 12px; }.ovv-head-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }--.ovv-head .ovv-byline { flex: 1 1 240px; padding: 0; border: 0; min-width: 0; }++/* The picture, the name and the date stay on one row; the actions go under them when the row is too narrow for both++ (a tablet put the name under the picture beside five buttons, 2026-10-27). */++.ovv-head .ovv-byline { flex: 1 1 300px; flex-wrap: nowrap; padding: 0; border: 0; min-width: 0; }++.ovv-head .ovv-byline-text { flex: 1 1 auto; }.ovv-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }.ovv-btn[hidden] { display: none; }@@@ -443,9 +449,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }}@media (max-width: 600px) {/* The actions in one row that scrolls sideways; their menus open as sheets from the bottom (video.js dims the page). */-- .ovv-actions { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -16px; padding: 2px 16px; width: calc(100% + 32px); }-- .ovv-actions::-webkit-scrollbar { display: none; }-- .ovv-actions .ovv-btn { white-space: nowrap; }++ /* The actions fit one row: each its icon over a short word, all the same width (they ran off the edge, "Sha…"). */++ .ovv-actions { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; width: 100%; }++ .ovv-actions > .ovv-btn, .ovv-actions > details > summary.ovv-btn { flex-direction: column; justify-content: center; gap: 4px; width: 100%; min-height: 54px; padding: 6px 2px; font-size: .72rem; line-height: 1.1; white-space: normal; text-align: center; border-radius: 12px; }++ .ovv-actions > details { min-width: 0; }++ .ovv-actions .ovv-btn i { font-size: 1rem; margin: 0; }++ .ovv-actions .ovv-btn--icon { width: 100%; }++ /* Less space before the footer: the page's own bottom and the footer's top margin made a band of nothing. */++ .ovv-page { padding-bottom: 20px; }.ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }.ovv-pop--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }.ovv-head .ovv-title { font-size: 1.2rem; }@@@ -40,12 +40,16 @@// 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;++ // The page may already know it (pages.js startAspect, from Live's stream history): then nothing moves on play.++ let current = Number(video.getAttribute('data-ovv-ar')) || 0;let on = false;const update = function () {const w = video.videoWidth;const h = video.videoHeight;-- if (!on || !(w > 0 && h > 0)) return;++ if (!(w > 0 && h > 0)) return;++ // Before playback: with no shape from the page the box waits (loading shifts nothing); with one, the file's own++ // size corrects it at once should Live's history have been wrong, and a match moves nothing.++ if (!on && !current) 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;@@@ -78,6 +78,23 @@ function thumbImg(url, sizes) {const CARD_SIZES = '(max-width: 520px) calc(100vw - 32px), (max-width: 820px) 45vw, 240px';// The player shows the item's picture until it plays: without a poster the page's largest paint waited for the first// video frame (3.8 s on a phone-speed CPU, 2026-10-17). A Media thumbnail is asked for as a 1280 px WebP, as uploads are.++/**++ * The picture's shape from the first paint (owner, 2026-10-27: on a phone, a recording made upright on a phone opened in++ * a 16:9 box with wide bars, and turned only once it played). Live's stream history keeps each stream's shapes (at,++ * width, height); the one in force where this video starts is its first. Null when Live has none (streams before++ * 2026-10-25): video.js then takes the shape once it plays, as before. Clamped like video.js followShape.++ */++function startAspect(hist, vod) {++ if (!hist || !Array.isArray(hist.shapes)) return null;++ const own = Array.isArray(hist.parts) ? hist.parts.find((p) => p && String(p.vod_id) === String(vod.id)) : null;++ const from = own && Number.isFinite(Number(own.at)) ? Number(own.at) : 0;++ const shapes = hist.shapes.filter((s) => s && Number(s.width) > 0 && Number(s.height) > 0).sort((a, b) => Number(a.at) - Number(b.at));++ let pick = shapes[0] || null;++ for (const s of shapes) if (Number(s.at) <= from + 1) pick = s;++ if (!pick) return null;++ return Math.round(Math.min(3.2, Math.max(0.4, Number(pick.width) / Number(pick.height))) * 10000) / 10000;++}++function posterAttr(url) {// A Media thumbnail comes in sizes: the poster at 1280 px, and a 32 px preview the player shows blurred (and takes its// background colour from) before the poster arrives. data-ov-ambient: the picture's glow around the player.@@@ -1139,8 +1156,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvPromise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),playlistForm(req, 'vod', id),upNext(req, 'vod', id),-- // One part of a stream recorded in parts (its broadcaster reconnected): the other parts, to move between.-- Promise.resolve().then(() => (vod.part ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),++ // The stream's history: the picture's shape where this video starts, and for one part of a stream recorded in++ // parts (its broadcaster reconnected) the other parts, to move between.++ Promise.resolve().then(() => (live.isId(vod.stream_id) ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),]);let mems = [];try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }@@@ -1184,8 +1202,10 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv// Numbered by their place in the list, each recording once: the stored part numbers repeat after a restart// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).const seenParts = new Set();-- const parts = hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(p.vod_id))) : [];++ const parts = vod.part && hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(p.vod_id))) : [];const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';++ const ar = hist && hist !== fail ? startAspect(hist, vod) : null;++ const shapeAttr = ar ? html` style="--ovv-ar: ${String(ar)}" data-ovv-ar="${String(ar)}"` : '';// The More menu: where else this lives, and the owner's tools.const moreItems = [];if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });@@@ -1208,7 +1228,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv<div class="ovv-col ovv-col--main"><div class="ovv-stage">${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}--<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>++<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div><div class="ovv-head"><h1 class="ovv-title">${vod.title || 'Stream recording'}</h1><div class="ovv-head-row">@@@ -170,6 +170,12 @@ function startLive() {if (memories[1] === '101') return json(200, { memories: [{ offset_seconds: 10, description: 'Intro & setup' }, { offset_seconds: 30, description: 'The <main> part' }] });return json(200, { memories: [] });}++ // Live's stream history: stream 1001 (VOD 101) was streamed upright from a phone, then turned sideways later.++ const history = /^\/api\/streams\/(\d+)\/history$/.exec(p);++ if (history) {++ if (history[1] === '1001') return json(200, { stream_id: 1001, parts: [{ part: 1, vod_id: 101, at: 3.2, end: 48 }], shapes: [{ at: 2.9, width: 720, height: 1280 }, { at: 40, width: 1280, height: 720 }] });++ return json(404, { error: 'not found' });++ }const vodDetail = /^\/api\/vods\/(\d+)$/.exec(p);if (vodDetail) {if (vodDetail[1] === '103') return json(404, { error: 'not found' });@@@ -272,6 +278,16 @@ function countOccurrences(haystack, needle) {live.setChatDown(false);});++ await check('the player opens in the recording\'s shape (an upright phone stream) from Live\'s stream history', async () => {++ const r = await getVideo('/v/101');++ assert.strictEqual(r.status, 200);++ const video = /<video class="ovv-player"[^>]*>/.exec(r.text)[0];++ assert.ok(video.includes('style="--ovv-ar: 0.5625"') && video.includes('data-ovv-ar="0.5625"'), video);++ assert.ok(!r.text.includes('The stream was recorded in'), 'a whole recording lists no parts');++ const other = await getVideo('/v/104'); // stream 1002: no history, so 16:9 until it plays, as before++ assert.ok(!/<video class="ovv-player"[^>]*--ovv-ar/.test(other.text));++ });++await check('the player shows the item\'s picture until it plays (a 1280 px WebP of a Media thumbnail)', async () => {const vod = await getVideo('/v/101');assert.ok(/<video class="ovv-player"[^>]* poster="https:\/\/openvibe\.media\/t\/101\.jpg\?w=1280&fmt=webp"/.test(vod.text), vod.text.match(/<video[^>]*>/)?.[0]);@@@ -18,7 +18,7 @@ vm.runInContext(`${m[0]}; this.followShape = followShape;`, ctx);const listeners = {};const vars = {};const video = {-- videoWidth: 0, videoHeight: 0,++ videoWidth: 0, videoHeight: 0, getAttribute: () => null,style: { setProperty: (k, v) => { vars[k] = v; } },addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },};@@@ -38,6 +38,26 @@ 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');++// The page already knows the shape (pages.js startAspect, from Live's stream history; owner, 2026-10-27: an upright++// phone recording opened in a 16:9 box): the box is upright from the first paint and the matching file moves nothing.++{++ const l2 = {};++ const v2 = {};++ const known = {++ videoWidth: 0, videoHeight: 0, getAttribute: (k) => (k === 'data-ovv-ar' ? '0.5625' : null),++ style: { setProperty: (k, v) => { v2[k] = v; } },++ addEventListener: (t, f) => { (l2[t] = l2[t] || []).push(f); },++ };++ const fire2 = (t) => (l2[t] || []).forEach((f) => f());++ ctx.followShape(known);++ known.videoWidth = 720; known.videoHeight = 1280; fire2('resize');++ assert.strictEqual(v2['--ovv-ar'], undefined, 'the file matches the page: nothing moves');++ fire2('playing');++ assert.strictEqual(v2['--ovv-ar'], undefined);++ known.videoWidth = 1280; known.videoHeight = 720; fire2('resize');++ assert.strictEqual(v2['--ovv-ar'], '1.7778', 'a turn mid-stream is still followed');++}++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\)/);
@@ -115,6 +115,9 @@ body:has(> main.ovv-page) .openvibe-navbar.ovnav-top { background-color: var(--b
@@ -115,6 +115,9 @@ body:has(> main.ovv-page) .openvibe-navbar.ovnav-top { background-color: var(--b
before that. The height is capped so the title under the player stays on screen. */
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); }
.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; } }
@media (prefers-reduced-motion: reduce) { .ovv-player { transition: none; } }
+/* An upright recording on a phone or tablet held upright: at most 70% of the screen, so its title and buttons show under
+ it without scrolling. */
+@media (max-width: 1000px) and (orientation: portrait) { .ovv-player { max-height: 70vh; max-height: 70svh; } }
.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; }
@@ -327,7 +330,10 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -327,7 +330,10 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.ovv-head { margin-top: 14px; }
.ovv-head { margin-top: 14px; }
.ovv-head .ovv-title { margin: 0 0 12px; }
.ovv-head .ovv-title { margin: 0 0 12px; }
.ovv-head-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
.ovv-head-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; }
-.ovv-head .ovv-byline { flex: 1 1 240px; padding: 0; border: 0; min-width: 0; }
+/* The picture, the name and the date stay on one row; the actions go under them when the row is too narrow for both
+ (a tablet put the name under the picture beside five buttons, 2026-10-27). */
+.ovv-head .ovv-byline { flex: 1 1 300px; flex-wrap: nowrap; padding: 0; border: 0; min-width: 0; }
+.ovv-head .ovv-byline-text { flex: 1 1 auto; }
.ovv-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.ovv-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ovv-btn--icon { width: 38px; padding: 0; justify-content: center; }
.ovv-btn[hidden] { display: none; }
.ovv-btn[hidden] { display: none; }
@@ -443,9 +449,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -443,9 +449,14 @@ a.ovv-channel-name:hover { text-decoration: underline; }
}
}
@media (max-width: 600px) {
@media (max-width: 600px) {
/* The actions in one row that scrolls sideways; their menus open as sheets from the bottom (video.js dims the page). */
/* The actions in one row that scrolls sideways; their menus open as sheets from the bottom (video.js dims the page). */
- .ovv-actions { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 0 -16px; padding: 2px 16px; width: calc(100% + 32px); }
- .ovv-actions::-webkit-scrollbar { display: none; }
- .ovv-actions .ovv-btn { white-space: nowrap; }
+ /* The actions fit one row: each its icon over a short word, all the same width (they ran off the edge, "Sha…"). */
+ .ovv-actions { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; width: 100%; }
+ .ovv-actions > .ovv-btn, .ovv-actions > details > summary.ovv-btn { flex-direction: column; justify-content: center; gap: 4px; width: 100%; min-height: 54px; padding: 6px 2px; font-size: .72rem; line-height: 1.1; white-space: normal; text-align: center; border-radius: 12px; }
+ .ovv-actions > details { min-width: 0; }
+ .ovv-actions .ovv-btn i { font-size: 1rem; margin: 0; }
+ .ovv-actions .ovv-btn--icon { width: 100%; }
+ /* Less space before the footer: the page's own bottom and the footer's top margin made a band of nothing. */
+ .ovv-page { padding-bottom: 20px; }
.ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
.ovv-pop-panel, .ovv-save[open] > .ovv-save-form, .ovv-more-menu .ovv-pop-panel { position: fixed; top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; max-height: 82vh; max-height: 82dvh; overflow-y: auto; border-radius: 16px; padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
.ovv-pop--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }
.ovv-pop--js[open]::before { content: ''; position: fixed; inset: 0; z-index: 39; background: rgba(0, 0, 0, .55); }
.ovv-head .ovv-title { font-size: 1.2rem; }
.ovv-head .ovv-title { font-size: 1.2rem; }
@@ -40,12 +40,16 @@
@@ -40,12 +40,16 @@
// change that keeps the ratio within 3% (a lowered resolution) moves nothing. Until playback starts the box stays
// 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.
// 16:9, so loading the page shifts nothing below it; video.css caps the height so the title stays on screen.
function followShape(video) {
function followShape(video) {
- let current = 0;
+ // The page may already know it (pages.js startAspect, from Live's stream history): then nothing moves on play.
+ let current = Number(video.getAttribute('data-ovv-ar')) || 0;
let on = false;
let on = false;
const update = function () {
const update = function () {
const w = video.videoWidth;
const w = video.videoWidth;
const h = video.videoHeight;
const h = video.videoHeight;
- if (!on || !(w > 0 && h > 0)) return;
+ if (!(w > 0 && h > 0)) return;
+ // Before playback: with no shape from the page the box waits (loading shifts nothing); with one, the file's own
+ // size corrects it at once should Live's history have been wrong, and a match moves nothing.
+ if (!on && !current) return;
const r = Math.min(3.2, Math.max(0.4, w / h));
const r = Math.min(3.2, Math.max(0.4, w / h));
if (current && Math.abs(Math.log(r / current)) < 0.03) return;
if (current && Math.abs(Math.log(r / current)) < 0.03) return;
current = r;
current = r;
@@ -78,6 +78,23 @@ function thumbImg(url, sizes) {
@@ -78,6 +78,23 @@ function thumbImg(url, sizes) {
const CARD_SIZES = '(max-width: 520px) calc(100vw - 32px), (max-width: 820px) 45vw, 240px';
const CARD_SIZES = '(max-width: 520px) calc(100vw - 32px), (max-width: 820px) 45vw, 240px';
// The player shows the item's picture until it plays: without a poster the page's largest paint waited for the first
// The player shows the item's picture until it plays: without a poster the page's largest paint waited for the first
// video frame (3.8 s on a phone-speed CPU, 2026-10-17). A Media thumbnail is asked for as a 1280 px WebP, as uploads are.
// video frame (3.8 s on a phone-speed CPU, 2026-10-17). A Media thumbnail is asked for as a 1280 px WebP, as uploads are.
+/**
+ * The picture's shape from the first paint (owner, 2026-10-27: on a phone, a recording made upright on a phone opened in
+ * a 16:9 box with wide bars, and turned only once it played). Live's stream history keeps each stream's shapes (at,
+ * width, height); the one in force where this video starts is its first. Null when Live has none (streams before
+ * 2026-10-25): video.js then takes the shape once it plays, as before. Clamped like video.js followShape.
+ */
+function startAspect(hist, vod) {
+ if (!hist || !Array.isArray(hist.shapes)) return null;
+ const own = Array.isArray(hist.parts) ? hist.parts.find((p) => p && String(p.vod_id) === String(vod.id)) : null;
+ const from = own && Number.isFinite(Number(own.at)) ? Number(own.at) : 0;
+ const shapes = hist.shapes.filter((s) => s && Number(s.width) > 0 && Number(s.height) > 0).sort((a, b) => Number(a.at) - Number(b.at));
+ let pick = shapes[0] || null;
+ for (const s of shapes) if (Number(s.at) <= from + 1) pick = s;
+ if (!pick) return null;
+ return Math.round(Math.min(3.2, Math.max(0.4, Number(pick.width) / Number(pick.height))) * 10000) / 10000;
+}
+
function posterAttr(url) {
function posterAttr(url) {
// A Media thumbnail comes in sizes: the poster at 1280 px, and a 32 px preview the player shows blurred (and takes its
// A Media thumbnail comes in sizes: the poster at 1280 px, and a 32 px preview the player shows blurred (and takes its
// background colour from) before the poster arrives. data-ov-ambient: the picture's glow around the player.
// background colour from) before the poster arrives. data-ov-ambient: the picture's glow around the player.
@@ -1139,8 +1156,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1139,8 +1156,9 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
playlistForm(req, 'vod', id),
upNext(req, 'vod', id),
upNext(req, 'vod', id),
- // One part of a stream recorded in parts (its broadcaster reconnected): the other parts, to move between.
- Promise.resolve().then(() => (vod.part ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),
+ // The stream's history: the picture's shape where this video starts, and for one part of a stream recorded in
+ // parts (its broadcaster reconnected) the other parts, to move between.
+ Promise.resolve().then(() => (live.isId(vod.stream_id) ? live.streamHistory(vod.stream_id) : null)).catch(() => fail),
]);
]);
let mems = [];
let mems = [];
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }
try { if (m !== fail) mems = chapters(moments(m && m.memories), vod.duration_seconds); } catch { mems = []; }
@@ -1184,8 +1202,10 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1184,8 +1202,10 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
// Numbered by their place in the list, each recording once: the stored part numbers repeat after a restart
// Numbered by their place in the list, each recording once: the stored part numbers repeat after a restart
// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).
// ("Part 1 (this one) · Part 2 · Part 1", 2026-10-27).
const seenParts = new Set();
const seenParts = new Set();
- const parts = hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(p.vod_id))) : [];
+ const parts = vod.part && hist && hist !== fail && Array.isArray(hist.parts) ? hist.parts.filter((p) => p && live.isId(p.vod_id) && !seenParts.has(String(p.vod_id)) && seenParts.add(String(p.vod_id))) : [];
const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';
const partNav = parts.length > 1 ? html`<p class="ovv-about-parts"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> The stream was recorded in ${parts.length} parts: ${parts.map((p, i) => [i ? ' · ' : '', String(p.vod_id) === id ? html`<b>Part ${i + 1} (this one)</b>` : html`<a href="/v/${p.vod_id}">Part ${i + 1}</a>`])}</p>` : '';
+ const ar = hist && hist !== fail ? startAspect(hist, vod) : null;
+ const shapeAttr = ar ? html` style="--ovv-ar: ${String(ar)}" data-ovv-ar="${String(ar)}"` : '';
// The More menu: where else this lives, and the owner's tools.
// The More menu: where else this lives, and the owner's tools.
const moreItems = [];
const moreItems = [];
if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
if (channelName) moreItems.push({ href: liveChannel, icon: 'fa-solid fa-tower-broadcast', label: 'Channel on OpenVibe.Live', hint: 'Live streams and chat' });
@@ -1208,7 +1228,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1208,7 +1228,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
-<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
+<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))}${shapeAttr} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-head-row">
<div class="ovv-head-row">
@@ -170,6 +170,12 @@ function startLive() {
@@ -170,6 +170,12 @@ function startLive() {
if (memories[1] === '101') return json(200, { memories: [{ offset_seconds: 10, description: 'Intro & setup' }, { offset_seconds: 30, description: 'The <main> part' }] });
if (memories[1] === '101') return json(200, { memories: [{ offset_seconds: 10, description: 'Intro & setup' }, { offset_seconds: 30, description: 'The <main> part' }] });
return json(200, { memories: [] });
return json(200, { memories: [] });
}
}
+ // Live's stream history: stream 1001 (VOD 101) was streamed upright from a phone, then turned sideways later.
+ const history = /^\/api\/streams\/(\d+)\/history$/.exec(p);
+ if (history) {
+ if (history[1] === '1001') return json(200, { stream_id: 1001, parts: [{ part: 1, vod_id: 101, at: 3.2, end: 48 }], shapes: [{ at: 2.9, width: 720, height: 1280 }, { at: 40, width: 1280, height: 720 }] });
+ return json(404, { error: 'not found' });
+ }
const vodDetail = /^\/api\/vods\/(\d+)$/.exec(p);
const vodDetail = /^\/api\/vods\/(\d+)$/.exec(p);
if (vodDetail) {
if (vodDetail) {
if (vodDetail[1] === '103') return json(404, { error: 'not found' });
if (vodDetail[1] === '103') return json(404, { error: 'not found' });
@@ -272,6 +278,16 @@ function countOccurrences(haystack, needle) {
@@ -272,6 +278,16 @@ function countOccurrences(haystack, needle) {
live.setChatDown(false);
live.setChatDown(false);
});
});
+ await check('the player opens in the recording\'s shape (an upright phone stream) from Live\'s stream history', async () => {
+ const r = await getVideo('/v/101');
+ assert.strictEqual(r.status, 200);
+ const video = /<video class="ovv-player"[^>]*>/.exec(r.text)[0];
+ assert.ok(video.includes('style="--ovv-ar: 0.5625"') && video.includes('data-ovv-ar="0.5625"'), video);
+ assert.ok(!r.text.includes('The stream was recorded in'), 'a whole recording lists no parts');
+ const other = await getVideo('/v/104'); // stream 1002: no history, so 16:9 until it plays, as before
+ assert.ok(!/<video class="ovv-player"[^>]*--ovv-ar/.test(other.text));
+ });
+
await check('the player shows the item\'s picture until it plays (a 1280 px WebP of a Media thumbnail)', async () => {
await check('the player shows the item\'s picture until it plays (a 1280 px WebP of a Media thumbnail)', async () => {
const vod = await getVideo('/v/101');
const vod = await getVideo('/v/101');
assert.ok(/<video class="ovv-player"[^>]* poster="https:\/\/openvibe\.media\/t\/101\.jpg\?w=1280&fmt=webp"/.test(vod.text), vod.text.match(/<video[^>]*>/)?.[0]);
assert.ok(/<video class="ovv-player"[^>]* poster="https:\/\/openvibe\.media\/t\/101\.jpg\?w=1280&fmt=webp"/.test(vod.text), vod.text.match(/<video[^>]*>/)?.[0]);
@@ -18,7 +18,7 @@ vm.runInContext(`${m[0]}; this.followShape = followShape;`, ctx);
@@ -18,7 +18,7 @@ vm.runInContext(`${m[0]}; this.followShape = followShape;`, ctx);
const listeners = {};
const listeners = {};
const vars = {};
const vars = {};
const video = {
const video = {
- videoWidth: 0, videoHeight: 0,
+ videoWidth: 0, videoHeight: 0, getAttribute: () => null,
style: { setProperty: (k, v) => { vars[k] = v; } },
style: { setProperty: (k, v) => { vars[k] = v; } },
addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
};
};
@@ -38,6 +38,26 @@ assert.strictEqual(vars['--ovv-ar'], '1.3333');
@@ -38,6 +38,26 @@ assert.strictEqual(vars['--ovv-ar'], '1.3333');
show(8000, 100);
show(8000, 100);
assert.strictEqual(vars['--ovv-ar'], '3.2000', 'a broken size is held to 32:10');
assert.strictEqual(vars['--ovv-ar'], '3.2000', 'a broken size is held to 32:10');
+// The page already knows the shape (pages.js startAspect, from Live's stream history; owner, 2026-10-27: an upright
+// phone recording opened in a 16:9 box): the box is upright from the first paint and the matching file moves nothing.
+{
+ const l2 = {};
+ const v2 = {};
+ const known = {
+ videoWidth: 0, videoHeight: 0, getAttribute: (k) => (k === 'data-ovv-ar' ? '0.5625' : null),
+ style: { setProperty: (k, v) => { v2[k] = v; } },
+ addEventListener: (t, f) => { (l2[t] = l2[t] || []).push(f); },
+ };
+ const fire2 = (t) => (l2[t] || []).forEach((f) => f());
+ ctx.followShape(known);
+ known.videoWidth = 720; known.videoHeight = 1280; fire2('resize');
+ assert.strictEqual(v2['--ovv-ar'], undefined, 'the file matches the page: nothing moves');
+ fire2('playing');
+ assert.strictEqual(v2['--ovv-ar'], undefined);
+ known.videoWidth = 1280; known.videoHeight = 720; fire2('resize');
+ assert.strictEqual(v2['--ovv-ar'], '1.7778', 'a turn mid-stream is still followed');
+}
+
assert.match(src, /followShape\(video\);\n\s+const t = startSeconds\(\);/, 'the watch page follows its video');
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');
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\)/);
assert.match(css, /\.ovv-player \{[^}]*aspect-ratio: var\(--ovv-ar, 16 \/ 9\);[^}]*max-height: calc\(100dvh - 150px\)/);