openvibers/openvibe.live Commits

Live: the browser check passes again (no-JS text for /chat and /search, pending styles only with scripting, Media's 429 is no picture yet, SFU error handler fixed) (#116)

OP OpenVibers committed 13 Oct 2026 · c33ce685b5 · 10 files, +94 −23

The host's browser check has failed every release since 977747d7 (Live#114), paging **OpenVibeBrowserCheckFailed** since 2026-10-12 23:04. Re-run 2026-10-13 19:25 on the host:
- `nojs` failed on `/chat` (89 characters) and `/search?q=minecraft` (107); the minimum is 200;
- `errors` failed on `/` and `/@Aimzly`.

**No-JavaScript text**
- **Cause.** Before Live#114, a direct visit to any route without JavaScript showed the **home** section, which is `active` in `index.html`, so `/chat` and `/search` scored the home page's 2,445 characters. Live#114's `activePage` correctly shows the route's own section. Those two are filled only by script.
- **Fix.** Their fragments (the server inlines them on a direct visit) gain a `<noscript>` paragraph: what needs JavaScript (the live chat over a WebSocket; search as you type) and links to what works without it (live channels, `/content`, recordings, clips, moments). Small styles go in `chatpage.css` / `search.css`.
- **Same root, other pages.** The pending-state rules from Live#114 now apply only `@media (scripting: enabled)`:
  - placeholder outlines;
  - sections held back until `ch-ready` / `mp-ready`;
  - the channel's reserved full viewport.

  Without scripting the data never arrives, so they hid parts of the page for good: `/vod/5726` showed 196 characters. Chrome 120+, Firefox 113+ and Safari 17+ keep the layout-shift fix.
- **Check.** Verified with the shared harness's no-JS mode: `<noscript>` text counts, and `(scripting: none)` matches.

**Console errors**
- **Thumbnail 429s.** `POST /api/thumbnails/generate/vod/5725|5730` passed through Media's 429: its job queue holds the tenant's 50 slots during the CMAF backfill. The proxy now answers 200 with no thumbnail, like its existing 404 case. It doesn't ask Media about that item again for 5 minutes, so the page shows its placeholder.
- **`stream-player.js`.** `recvTransport` was a `const` inside the SFU setup's `try`, and the `catch` used it. Any failed setup threw `ReferenceError: recvTransport is not defined` from the error handler, so the player neither re-watched nor showed an error; the viewer was left on the loading card. It is now declared before the `try`.
- **Unsupported browsers.** A browser mediasoup-client cannot drive (`UnsupportedError`, including the headless check browser) now gets "This browser cannot play this live stream…" at once, with a warning instead of an error. Before, it re-watched 25 times, 50 s or more.

**Tests**
- `test/first-paint-stability.test.js`: every pending-state rule sits inside `@media (scripting: enabled)`; the chat and search fragments carry at least 200 characters of `<noscript>` text.
- `test/media-privacy.test.js`: Media's 429 answers 200 with no thumbnail, and Media is asked once across three requests.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 95b8cbb3..e7211f54 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1129,12 +1129,15 @@
.channel-below-fold { padding-top: 6px; }
/* Before the channel's data (app-channel.js _channelReady): the page keeps a full viewport (below) so the footer stays
under the fold, the tabs stay out of the layout until what lands above them is in, and the header shows outlines
-- rather than its placeholder words ("Channel", "0 followers"). */
--#page-channel:not(.ch-ready) .channel-below-fold { display: none; }
--#page-channel:not(.ch-data) :is(#ch-display-name-offline, #ch-username-offline, #ch-follower-count-offline, #ch-category-badge-offline) {
-- width: fit-content; color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-color: transparent; border-radius: 6px;
++ rather than its placeholder words ("Channel", "0 followers"). Only with scripting: without it the data never
++ comes, and the page shows what the server sent, all of it. */
++@media (scripting: enabled) {
++ #page-channel:not(.ch-ready) .channel-below-fold { display: none; }
++ #page-channel:not(.ch-data) :is(#ch-display-name-offline, #ch-username-offline, #ch-follower-count-offline, #ch-category-badge-offline) {
++ width: fit-content; color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-color: transparent; border-radius: 6px;
++ }
++ #page-channel:not(.ch-data) #ch-avatar-offline { color: transparent; }
}
--#page-channel:not(.ch-data) #ch-avatar-offline { color: transparent; }
.ch-analytics-period-btns { display:flex; gap:4px; margin-left:auto; }
.analytics-stat-cards {
display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
@@@ -1549,8 +1552,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer
whenever the open tab was short. */
#page-channel.page { min-height: 0; }
--/* …except before its data, while it is the placeholder markup (see _channelReady). */
--#page-channel.page:not(.ch-ready) { min-height: 100vh; }
++/* …except before its data, while it is the placeholder markup (see _channelReady; with scripting, as above). */
++@media (scripting: enabled) { #page-channel.page:not(.ch-ready) { min-height: 100vh; } }
/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */
.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }
ddiff --git a/public/css/features/chatpage.css b/public/css/features/chatpage.css
iindex 8e7ee735..2367d049 100644
---- a/public/css/features/chatpage.css
++++ b/public/css/features/chatpage.css
@@@ -341,3 +341,6 @@
.vc-connected-panel.is-open { animation: vcPanelIn 220ms cubic-bezier(.2, .9, .3, 1); }
@keyframes vcPanelIn { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: reduce) { .vc-channel-item.is-entering, .vc-channel-item.is-leaving, .vc-connected-panel.is-open { animation: none; } }
++
++/* Without JavaScript (the server inlines this fragment for a direct visit): what needs it, and where to go instead. */
++.chat-noscript { max-width: 720px; margin: 24px auto; padding: 0 16px; line-height: 1.6; }
ddiff --git a/public/css/features/media.css b/public/css/features/media.css
iindex ba7b710a..4a412ab3 100644
---- a/public/css/features/media.css
++++ b/public/css/features/media.css
@@@ -109,14 +109,17 @@
}
.vod-player-info { margin-bottom: 24px; }
/* Before a VOD's or clip's data (app-media.js _mediaReady): outlines for the placeholder words, and the parts that
-- arrive on their own (AI timeline, activity strip, clips, comments) stay out of the layout until they are all in. */
--:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #vp-streamer, #vp-duration, #vp-views, #clp-title, #clp-streamer, #clp-duration, #clp-views) {
-- color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-radius: 6px;
--}
--:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #clp-title) { width: fit-content; }
--:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar, #clp-avatar, .vod-player-meta i) { visibility: hidden; }
--#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }
--#page-clip-player:not(.mp-ready) #clp-comments { display: none; }
++ arrive on their own (AI timeline, activity strip, clips, comments) stay out of the layout until they are all in.
++ Only with scripting: without it the data never comes, and the page shows what the server sent, all of it. */
++@media (scripting: enabled) {
++ :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #vp-streamer, #vp-duration, #vp-views, #clp-title, #clp-streamer, #clp-duration, #clp-views) {
++ color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-radius: 6px;
++ }
++ :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #clp-title) { width: fit-content; }
++ :is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar, #clp-avatar, .vod-player-meta i) { visibility: hidden; }
++ #page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }
++ #page-clip-player:not(.mp-ready) #clp-comments { display: none; }
++}
.vod-player-info {
background: rgba(22,22,26,0.74);
border: 1px solid rgba(255,255,255,0.05);
ddiff --git a/public/css/features/search.css b/public/css/features/search.css
iindex 05bb40e3..c549eadc 100644
---- a/public/css/features/search.css
++++ b/public/css/features/search.css
@@@ -36,3 +36,6 @@
.ls-facet button[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }
.ls-facet button .ls-count { color: var(--text-muted); margin-left: 4px; }
++
++/* Without JavaScript (the server inlines this fragment for a direct visit): what needs it, and where to go instead. */
++.ls-noscript { margin: 16px 0 0; line-height: 1.6; }
ddiff --git a/public/fragments/chat.html b/public/fragments/chat.html
iindex e975fc7b..950c6988 100644
---- a/public/fragments/chat.html
++++ b/public/fragments/chat.html
@@@ -1,4 +1,10 @@
<!-- #page-chat markup, loaded on first visit to the route (public/features.json). The server inlines it for direct visits. -->
++ <noscript><div class="chat-noscript">
++ <h2>OpenVibe.Live chat</h2>
++ <p>The chat room and its voice channels need JavaScript, which is turned off in this browser: messages arrive live
++ over a WebSocket. Turn it on to read and join the conversation, or browse what is on without it:
++ <a href="/">live channels</a>, <a href="/vods">recordings</a>, <a href="/clips">clips</a> and <a href="/moments">moments</a>.</p>
++ </div></noscript>
<div class="chat-voice-layout">
<!-- ── Left: Voice Channels ── -->
<div class="vc-sidebar">
ddiff --git a/public/fragments/search.html b/public/fragments/search.html
iindex d76267fd..afbb0b56 100644
---- a/public/fragments/search.html
++++ b/public/fragments/search.html
@@@ -15,6 +15,9 @@
<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
</div>
</form>
++ <noscript><p class="ls-noscript">Search results on OpenVibe.Live load with JavaScript, which is turned off in this browser.
++ Turn it on to search channels, recordings and clips as you type, or browse without it:
++ <a href="/content">everything on OpenVibe.Live</a>, <a href="/vods">recordings</a>, <a href="/clips">clips</a> and <a href="/moments">moments</a>.</p></noscript>
<div class="ls-facets" hidden>
<div class="ls-facet" data-ls-facet="category" role="group" aria-label="Category"></div>
<div class="ls-facet" data-ls-facet="channel" role="group" aria-label="Channel"></div>
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 1d10659e..9679123f 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -1474,6 +1474,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
return;
}
++ // Declared out here: the catch below closes it (a const inside the try was out of the catch's scope, so a
++ // failed setup threw a ReferenceError there and never re-watched or showed an error).
++ let recvTransport = null;
try {
// Step 1: Load mediasoup-client
updateStatus('Starting playback', {
@@@ -1506,7 +1509,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
}
console.log('[Player] Using SFU ICE servers:', iceServers.map((s) => (Array.isArray(s.urls) ? s.urls.join(',') : s.urls)).join(' | '));
-- const recvTransport = device.createRecvTransport({
++ recvTransport = device.createRecvTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
@@@ -1879,14 +1882,21 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
console.log(`[Player] SFU viewer consuming ${consumedCount} track(s)`);
} catch (err) {
++ // A browser mediasoup-client has no WebRTC handler for fails the same way on every try: say so now
++ // instead of re-watching 25 times first.
++ if (err && err.name === 'UnsupportedError') {
++ console.warn('[Player] SFU viewing is not supported in this browser:', err.message);
++ showStreamError('This browser cannot play this live stream. Try a current Chrome, Edge, Firefox or Safari.');
++ return;
++ }
console.error('[Player] SFU viewer setup failed:', err);
// Close the transport explicitly so the server sees it as dead on the next
// watch — otherwise the server's re-watch guard keeps the old transport alive
// and never sends a fresh sfu-viewer-ready (root cause of the Firefox retry loop).
-- if (player._sfuRecvTransport === recvTransport) {
++ if (recvTransport && player._sfuRecvTransport === recvTransport) {
player._sfuRecvTransport = null;
}
-- try { recvTransport.close(); } catch (_) {}
++ try { if (recvTransport) recvTransport.close(); } catch (_) {}
_totalIceFailures++;
if (_totalIceFailures < MAX_ICE_FAILURES) {
scheduleRewatch(2000);
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex 821c8140..9990b548 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -53,7 +53,11 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (r
// 404, and that stays true. Every visitor's page asks again for a missing thumbnail, so the answer is kept for
// an hour and given as 200 with no thumbnail: the page shows its placeholder, with no failed request in the
// console and no new Media job (VODs 5718 and 5721 logged a 500 on every home-page visit, 2026-10-12).
++// Media answers 429 when its job queue is full (a backfill can hold the tenant's 50 slots for hours): no picture
++// YET, and nothing failed here. That answer is kept for 5 minutes and given as 200 with no thumbnail too; the
++// 429 used to reach every visitor's console (the browser check of release 977747d7, 2026-10-12).
const NO_PICTURE_MS = 60 * 60 * 1000;
++const MEDIA_BUSY_MS = 5 * 60 * 1000;
const noPicture = new Map(); // `${kind}:${id}` → until (ms), oldest first
function noPictureKnown(key) {
const until = noPicture.get(key);
@@@ -62,9 +66,9 @@ function noPictureKnown(key) {
noPicture.delete(key);
return false;
}
--function rememberNoPicture(key) {
++function rememberNoPicture(key, ms = NO_PICTURE_MS) {
if (noPicture.size >= 5000) noPicture.delete(noPicture.keys().next().value);
-- noPicture.set(key, Date.now() + NO_PICTURE_MS);
++ noPicture.set(key, Date.now() + ms);
}
async function generateFor(kind, id, req, res) {
@@@ -80,8 +84,8 @@ async function generateFor(kind, id, req, res) {
if (noPictureKnown(key)) return res.json({ thumbnail_url: null });
let out;
try { out = await media.generateThumbnail(kind, id); } catch (err) {
-- if (!(err && err.name === 'MediaApiError' && err.status === 404)) throw err;
-- rememberNoPicture(key);
++ if (!(err && err.name === 'MediaApiError' && (err.status === 404 || err.status === 429))) throw err;
++ rememberNoPicture(key, err.status === 429 ? MEDIA_BUSY_MS : NO_PICTURE_MS);
return res.json({ thumbnail_url: null });
}
const url = media.publicUrl(out?.url || meta.thumbnail_url);
ddiff --git a/test/first-paint-stability.test.js b/test/first-paint-stability.test.js
iindex 0703067d..a988ce05 100644
---- a/test/first-paint-stability.test.js
++++ b/test/first-paint-stability.test.js
@@@ -3,7 +3,7 @@
// own section active (server/web/assets.js activePage) and the router takes it as the current page; the channel, VOD
// and clip pages hold back the parts that arrive on their own until they are all in; the player keeps a 16:9 box; the
// home hero reserves its countdown line, its "shipped" line and its stats board; and the widgets built before their
--// stylesheets stay out of the page.
++// stylesheets stay out of the page. Without JavaScript none of that waiting applies.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@@ -71,4 +71,26 @@ const inline = /<style id="ovsk-css">([\s\S]*?)<\/style>/.exec(index)[1];
assert.ok(inline.includes('.cosmetics-modal-overlay:not(.active),.messenger-panel:not(.open){display:none}') && /\.messenger-toggle\{position:fixed;/.test(inline), 'closed widgets stay out of the page until their stylesheets land');
ok('the home hero reserves its late lines and the early widgets stay out of the flow');
++// ── Without JavaScript ──
++// The data never comes, so the pending-state rules apply only with scripting; and a route whose section is only filled
++// by script says so, with links (the browser check needs 200 characters of text: /chat and /search had 89 and 107
++// once a direct visit showed their own section instead of the home page, 2026-10-12).
++const scriptingBlock = (css, rule) => {
++ const at = css.indexOf(rule);
++ const open = css.lastIndexOf('@media (scripting: enabled) {', at);
++ return at > 0 && open !== -1 && css.slice(open, at).split('}').length - 1 < css.slice(open, at).split('{').length - 1;
++};
++for (const rule of ['#page-channel:not(.ch-ready) .channel-below-fold { display: none; }', '#page-channel:not(.ch-data) #ch-avatar-offline', '#page-channel.page:not(.ch-ready) { min-height: 100vh; }']) {
++ assert.ok(scriptingBlock(chCss, rule), `channel.css: "${rule}" only with scripting`);
++}
++for (const rule of ['#page-vod-player:not(.mp-ready)', '#page-clip-player:not(.mp-ready) #clp-comments', ':is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar']) {
++ assert.ok(scriptingBlock(mCss, rule), `media.css: "${rule}" only with scripting`);
++}
++const noscriptText = (html) => ((/<noscript>([\s\S]*?)<\/noscript>/.exec(html) || [])[1] || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
++for (const frag of ['chat', 'search']) {
++ const text = noscriptText(read(`public/fragments/${frag}.html`));
++ assert.ok(text.length >= 200, `${frag}: ${text.length} characters without JavaScript`);
++}
++ok('without JavaScript nothing waits for data, and the script-only routes say what needs it');
++
console.log(`first-paint-stability: ${pass} checks passed`);
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex 8e86a5f7..0fca50d3 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -233,6 +233,20 @@ async function check(name, fn) {
} finally { media.generateThumbnail = realGenerate; }
});
++ await check('thumbnail regeneration: Media\'s full job queue (429) is no picture yet, not an error, and is not asked again at once', async () => {
++ const realGenerate = media.generateThumbnail;
++ VODS[104] = { id: 104, user_id: 3, title: 'Brand new VOD', visibility: 'public', is_public: 1, status: 'ready' };
++ let asked = 0;
++ media.generateThumbnail = async () => { asked++; throw new media.MediaApiError('At most 50 jobs may be queued or running per tenant', 429, { error: 'At most 50 jobs may be queued or running per tenant' }); };
++ try {
++ for (let i = 0; i < 3; i++) {
++ const r = await call('POST', '/api/thumbnails/generate/vod/104', null);
++ assert.deepStrictEqual([r.status, r.json && r.json.thumbnail_url], [200, null], r.text);
++ }
++ assert.strictEqual(asked, 1, 'a busy answer is remembered for a while');
++ } finally { media.generateThumbnail = realGenerate; delete VODS[104]; }
++ });
++
await check('thumbnail by id (image URLs): the current thumbnail of a visible item, a private one looks missing', async () => {
for (const id of [100, 101, 102, 103]) VODS[id].thumbnail_url = `/t/vod-${id}-7.jpg`;
CLIPS[200].thumbnail_url = '/t/clip-200-7.jpg';