openvibers/openvibe.mediahub Commits
Watch page: the video first, on every screen (#373)
Changes
@@@ -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; }++}@@@ -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);@@@ -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) {@@@ -143,11 +143,11 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, sameawait 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');});@@@ -63,4 +63,33 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.cassert.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');
@@ -824,3 +824,48 @@ html[data-ov-motion=off] .ovv-stat:hover{transform: none}
@@ -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, .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; }
.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; }
+}
@@ -43,6 +43,10 @@
@@ -43,6 +43,10 @@
// The page may already know it (pages.js startAspect, from Live's stream history): then nothing moves on play.
// 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 current = Number(video.getAttribute('data-ovv-ar')) || 0;
let on = false;
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 update = function () {
const w = video.videoWidth;
const w = video.videoWidth;
const h = video.videoHeight;
const h = video.videoHeight;
@@ -54,6 +58,7 @@
@@ -54,6 +58,7 @@
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;
video.style.setProperty('--ovv-ar', r.toFixed(4));
video.style.setProperty('--ovv-ar', r.toFixed(4));
+ mark();
};
};
video.addEventListener('playing', function () { on = true; update(); });
video.addEventListener('playing', function () { on = true; update(); });
video.addEventListener('resize', update);
video.addEventListener('resize', update);
@@ -1027,6 +1027,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -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
* 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.
* 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) {
async function moreFromChannel(username, name, kind, id) {
if (!username) return '';
if (!username) return '';
@@ -1039,7 +1040,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -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.
// 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);
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 '';
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) {
async function upNext(req, kind, id) {
@@ -143,11 +143,11 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
@@ -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 () => {
await check('beside the player: more from the channel, never the one playing', async () => {
const v = await video('/v/301');
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 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');
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 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(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');
assert.ok(/Kim labelled/.test(moreC) && !/Kim clip/.test(moreC), 'on a clip: the channel\'s recording, not the clip playing');
});
});
@@ -63,4 +63,33 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.c
@@ -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\)/);
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).
// 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;'));
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');