openvibers/openvibe.mediahub Commits

Watch page: the video first, on every screen (#373)

OP OpenVibers committed 3 days ago · a67ae4e61c · 5 files, +84 −4

On a phone "More from <channel>" stood above the player: the section (11-09) had no place in the one-column order, so
it took the first. Its class was also the home page's link list (.ovv-more), whose boxes went round every thumbnail
and title beside the player on a computer.

- The section is .ovv-also, last in the one-column order, with a link to all of the channel's videos.
- The lists beside the player (more from the channel, the stream's clips) are rows: a thumbnail, then the title and
  its date. On a tablet held upright they are cards.
- A phone: the player runs from edge to edge under the bar and stays there while the page moves under it; when the
  bar steps away on the way down, the player takes its place. An upright picture is never pinned (video.js marks its
  frame). The tabs take what is left of the screen under the pinned player.
- test/video-shape.test.js: every piece of the page has a place in the one-column order.

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 28b143e..19112d2 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -824,3 +824,48 @@ html[data-ov-motion=off] .ovv-stat:hover{transform: none}
.ovv-watch .ovv-actions .ovv-btn, .ovv-watch .ovv-actions details > summary.ovv-btn { min-height: 44px; padding: 0 16px; font-size: .84rem; }
.ovv-watch .ovv-actions .ovv-btn--icon { width: 44px; padding: 0; }
}
++
++/* ── The watch page, the video first (2026-11-10) ───────────────────────────────────────────────────────────────────
++ The owner, with a recording open on a phone: "why is the video not at the top … more device aware and viewport
++ aware and better on mobile versus desktop". "More from <channel>" (11-09) had no place in the one-column order, so it
++ stood above the player; and its class was an older link list's, whose boxes went round every thumbnail and title. It
++ is .ovv-also now, and it comes last.
++ - A phone: the player from edge to edge under the bar, and it stays there while the page moves under it (a wide
++ picture only: an upright one would take the screen). Then the title and its actions, the owner's tools, the tabs,
++ up next, About, the stream's clips, comments, and more from the channel.
++ - A tablet held upright: one column, the same order, the lists as cards.
++ - A computer: the lists beside the player are rows: a thumbnail, then the title and its date. */
++.ovv-also > .ovv-panel-head { margin-bottom: 12px; }
++@media (max-width: 600px), (min-width: 1001px) {
++ .ovv-grid--side { grid-template-columns: minmax(0, 1fr); gap: 12px; }
++ .ovv-grid--side > .ovv-card { flex-direction: row; align-items: flex-start; gap: 12px; }
++ .ovv-grid--side > .ovv-card > :is(.ovv-card-media, .ovv-card-cover) { flex: 0 0 44%; max-width: 172px; border-radius: 10px; }
++ .ovv-grid--side > .ovv-card > .ovv-card-cover > .ovv-card-media { border-radius: inherit; }
++ .ovv-grid--side > .ovv-card > .ovv-card-body { flex: 1 1 0; padding-top: 1px; }
++ .ovv-grid--side .ovv-card-title { font-size: .9rem; line-height: 1.3; -webkit-line-clamp: 3; }
++ /* In a row the thumbnail is the large thing to press, so the title's link is its three lines and no taller. */
++ .ovv-grid--side .ovv-card-title a { min-height: 0; -webkit-line-clamp: 3; }
++ .ovv-grid--side .ovv-card-meta { margin-top: .3rem; font-size: .78rem; }
++ .ovv-grid--side :is(.ovv-dur, .ovv-tag) { bottom: 5px; padding: 0 5px; font-size: .68rem; border-radius: 5px; }
++ .ovv-grid--side .ovv-dur { right: 5px; }
++ .ovv-grid--side .ovv-tag { left: 5px; }
++}
++@media (max-width: 1000px) {
++ .ovv-also { order: 7; }
++}
++@media (max-width: 600px) {
++ .ovv-page:has(.ovv-watch) { padding-top: 0; }
++ .ovv-watch .ovv-stage { display: contents; }
++ .ovv-watch .ovv-stage > * { order: 1; }
++ .ovv-watch .ovv-player-wrap { margin: 0 -16px; max-width: none; border: 0; border-radius: 0; }
++ /* Pinned under the bar; the bar steps away on the way down the page (navbar.js: .ovnav-hide), and the player
++ takes its place at the same pace. */
++ .ovv-watch .ovv-player-wrap:not(.is-upright) { position: sticky; top: var(--ovnav-real-h, 52px); z-index: 30; box-shadow: 0 10px 18px -14px rgba(0, 0, 0, .7); transition: top .32s cubic-bezier(.2, .7, .2, 1); }
++ html:has(.openvibe-navbar.ovnav-hide) .ovv-watch .ovv-player-wrap:not(.is-upright) { top: 0; }
++ .ovv-watch .ovv-head { margin-top: 0; }
++ .ovv-watch .ovv-title-line { margin-bottom: 10px; }
++ /* The tabs (chat replay, chapters, transcript) take what is left of the screen under the pinned player. */
++ .ovv-watch:has(.ovv-player-wrap:not(.is-upright)) :is(.ovv-companion--tabs, .ovv-companion--one) { height: max(320px, calc(100dvh - var(--ovnav-real-h, 52px) - 56.25vw - var(--ovnav-appbar-h, 0px) - 20px)); }
++ /* The lists are the page's own here, not boxes in it. */
++ .ovv-watch :is(.ovv-also, .ovv-clips) { padding: 6px 0 0; border: 0; border-radius: 0; background: none; }
++}
ddiff --git a/public/js/video.js b/public/js/video.js
iindex b7b6851..840db78 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -43,6 +43,10 @@
// 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;
++ // An upright picture is marked on its frame: a phone pins a wide player under the bar, never an upright one.
++ const frame = video.closest ? video.closest('.ovv-player-wrap') : null;
++ const mark = function () { if (frame) frame.classList.toggle('is-upright', current > 0 && current < 1); };
++ mark();
const update = function () {
const w = video.videoWidth;
const h = video.videoHeight;
@@@ -54,6 +58,7 @@
if (current && Math.abs(Math.log(r / current)) < 0.03) return;
current = r;
video.style.setProperty('--ovv-ar', r.toFixed(4));
++ mark();
};
video.addEventListener('playing', function () { on = true; update(); });
video.addEventListener('resize', update);
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex f810c0c..e09b6a0 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -1027,6 +1027,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
/**
* More from the channel, beside the player (11-09: the watch page's right side stood empty on a computer): its other
* public recordings and clips, newest first, never the one playing. Live not answering leaves the section out.
++ * Its class is .ovv-also: .ovv-more is the home page's link list, whose boxes went round every thumbnail here.
*/
async function moreFromChannel(username, name, kind, id) {
if (!username) return '';
@@@ -1039,7 +1040,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// On a recording, its own clips are in "Clips from this stream" already: only the channel's other clips here.
const clipsOf = (Array.isArray(c && c.clips) ? c.clips : []).filter(pub).filter((x) => !(kind === 'clip' && Number(x.id) === Number(id)) && !(kind === 'vod' && Number(x.vod_id) === Number(id))).slice(0, 4);
if (!vods.length && !clipsOf.length) return '';
-- return html`<section class="ovv-panel ovv-more" aria-labelledby="h-more"><h2 id="h-more">More from ${name || username}</h2><div class="ovv-grid ovv-grid--side">${vods.map(vodCard)}${clipsOf.map(clipCard)}</div></section>`;
++ return html`<section class="ovv-panel ovv-also" aria-labelledby="h-more"><header class="ovv-panel-head"><h2 id="h-more">More from ${name || username}</h2><a class="ovv-panel-link" href="/@${username}">All videos</a></header><div class="ovv-grid ovv-grid--side">${vods.map(vodCard)}${clipsOf.map(clipCard)}</div></section>`;
}
async function upNext(req, kind, id) {
ddiff --git a/test/labels-ui.test.js b/test/labels-ui.test.js
iindex 0c4cacc..1786ec5 100644
---- a/test/labels-ui.test.js
++++ b/test/labels-ui.test.js
@@@ -143,11 +143,11 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
await check('beside the player: more from the channel, never the one playing', async () => {
const v = await video('/v/301');
-- const more = (v.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];
++ const more = (v.text.split('<section class="ovv-panel ovv-also"')[1] || '').split('</section>')[0];
assert.ok(!/Kim labelled/.test(more), 'never the recording playing');
assert.ok(!/Kim clip/.test(more), 'its own clips are in Clips from this stream, not again here');
const c = await video('/c/401');
-- const moreC = (c.text.split('<section class="ovv-panel ovv-more"')[1] || '').split('</section>')[0];
++ const moreC = (c.text.split('<section class="ovv-panel ovv-also"')[1] || '').split('</section>')[0];
assert.ok(moreC.includes('More from Kim'), 'the section names the channel');
assert.ok(/Kim labelled/.test(moreC) && !/Kim clip/.test(moreC), 'on a clip: the channel\'s recording, not the clip playing');
});
ddiff --git a/test/video-shape.test.js b/test/video-shape.test.js
iindex 447483b..fb59a76 100644
---- a/test/video-shape.test.js
++++ b/test/video-shape.test.js
@@@ -63,4 +63,33 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.c
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');
++
++// The video first (2026-11-10). An upright picture is marked on its frame, and only a wide one is pinned on a phone.
++{
++ const marks = {}; const l3 = {};
++ const tall = {
++ videoWidth: 0, videoHeight: 0, getAttribute: (k) => (k === 'data-ovv-ar' ? '0.5625' : null),
++ style: { setProperty: () => {} }, closest: (sel) => (sel === '.ovv-player-wrap' ? { classList: { toggle: (c, on) => { marks[c] = on; } } } : null),
++ addEventListener: (t, f) => { (l3[t] = l3[t] || []).push(f); },
++ };
++ ctx.followShape(tall);
++ assert.strictEqual(marks['is-upright'], true, 'a recording the page knows is upright is marked before it plays');
++ (l3.playing || []).forEach((f) => f());
++ tall.videoWidth = 1280; tall.videoHeight = 720; (l3.resize || []).forEach((f) => f());
++ assert.strictEqual(marks['is-upright'], false, 'turned on its side mid-stream, the mark goes');
++}
++const phone = css.slice(css.lastIndexOf('@media (max-width: 600px) {'));
++assert.match(phone, /\.ovv-watch \.ovv-player-wrap:not\(\.is-upright\) \{ position: sticky; top: var\(--ovnav-real-h, 52px\);/, 'a phone pins a wide player under the bar');
++assert.match(phone, /html:has\(\.openvibe-navbar\.ovnav-hide\) \.ovv-watch \.ovv-player-wrap:not\(\.is-upright\) \{ top: 0; \}/, 'and at the top once the bar has stepped away');
++assert.ok(!/\.is-upright\) \{[^}]*position: sticky[^}]*\}/.test(phone.replace(/:not\(\.is-upright\)/g, '')), 'an upright player is never pinned');
++// Every piece of the page has its place in the one-column order: one without sat above the player ("More from", 11-09).
++const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
++assert.ok(!/class="ovv-panel ovv-more"/.test(pages), 'the section beside the player is not the home page\'s .ovv-more link list');
++const ordered = {};
++// The last rule for a piece wins: the owner's tools were seventh, then moved under the player.
++Array.from(css.matchAll(/\n {2}\.(ovv-[a-z]+) \{ order: (\d); \}/g)).forEach((x) => { ordered[x[1]] = Number(x[2]); });
++assert.deepStrictEqual(ordered, { 'ovv-stage': 1, 'ovv-owner': 1, 'ovv-companion': 2, 'ovv-upnext': 3, 'ovv-about': 4, 'ovv-clips': 5, 'ovv-comments': 6, 'ovv-also': 7 }, 'the order of a watch page in one column');
++const pieces = Array.from(pages.matchAll(/class="(?:ovv-panel )?(ovv-(?:also|clips|comments|companion|upnext|about|owner))[ "]/g)).map((x) => x[1]);
++assert.ok(pieces.includes('ovv-also') && pieces.includes('ovv-clips') && pieces.includes('ovv-comments'), 'the pieces of the page are found in pages.js');
++pieces.forEach((cls) => assert.ok(ordered[cls] >= 1, `${cls} has a place in the one-column order`));
++console.log('✅ watch page player: the picture\'s shape once it plays, turns followed, resolution drops ignored; the video first');