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)

OP OpenVibers committed 17 days ago · 61028eb56b · 5 files, +82 −11

The owner (2026-10-27): the watch page on a phone wasted space and still was not great after a refresh.
- The player opens in the recording's shape from the first paint: Live's stream history keeps each stream's shapes,
  and the one in force where the video starts sets --ovv-ar (an upright phone stream was a 16:9 box with wide bars
  that turned only once it played). Streams without history keep 16:9 until they play, as before.
- On a phone or tablet held upright the player is at most 70% of the screen, so the title, channel and buttons show.
- On a phone the actions are one row of equal buttons, icon over word (they ran off the edge: "Sha…").
- The channel's picture, name and date stay on one row; the buttons go under them when there is no room (a tablet put
  the name under the picture).
- Less empty space before the footer on a phone.

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 7b49d44..9db754e 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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. */
.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; }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex ac9611c..ccbef9d 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -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;
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 55d78cc..3ac43ce 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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="ovv
Promise.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">
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex e3154ca..06ad5cb 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -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&amp;fmt=webp"/.test(vod.text), vod.text.match(/<video[^>]*>/)?.[0]);
ddiff --git a/test/video-shape.test.js b/test/video-shape.test.js
iindex fede33e..447483b 100644
---- a/test/video-shape.test.js
++++ b/test/video-shape.test.js
@@@ -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\)/);