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)
Changes
@@@ -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 staysunder 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 footerwhenever 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; }@@@ -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; }@@@ -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);@@@ -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; }@@@ -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">@@@ -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>@@@ -1474,6 +1474,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatcreturn;}++ // 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-clientupdateStatus('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, scheduleRewatcconsole.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);@@@ -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 firstfunction 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);@@@ -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`);@@@ -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';
@@ -1129,12 +1129,15 @@
@@ -1129,12 +1129,15 @@
.channel-below-fold { padding-top: 6px; }
.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
/* 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
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; }
.ch-analytics-period-btns { display:flex; gap:4px; margin-left:auto; }
.analytics-stat-cards {
.analytics-stat-cards {
display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
@@ -1549,8 +1552,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -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
/* 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. */
whenever the open tab was short. */
#page-channel.page { min-height: 0; }
#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). */
/* 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; }
.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }
@@ -341,3 +341,6 @@
@@ -341,3 +341,6 @@
.vc-connected-panel.is-open { animation: vcPanelIn 220ms cubic-bezier(.2, .9, .3, 1); }
.vc-connected-panel.is-open { animation: vcPanelIn 220ms cubic-bezier(.2, .9, .3, 1); }
@keyframes vcPanelIn { from { opacity: 0; transform: translateY(6px); } }
@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; } }
@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; }
@@ -109,14 +109,17 @@
@@ -109,14 +109,17 @@
}
}
.vod-player-info { margin-bottom: 24px; }
.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
/* 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 {
.vod-player-info {
background: rgba(22,22,26,0.74);
background: rgba(22,22,26,0.74);
border: 1px solid rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.05);
@@ -36,3 +36,6 @@
@@ -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[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; }
.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; }
@@ -1,4 +1,10 @@
@@ -1,4 +1,10 @@
<!-- #page-chat markup, loaded on first visit to the route (public/features.json). The server inlines it for direct visits. -->
<!-- #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">
<div class="chat-voice-layout">
<!-- ── Left: Voice Channels ── -->
<!-- ── Left: Voice Channels ── -->
<div class="vc-sidebar">
<div class="vc-sidebar">
@@ -15,6 +15,9 @@
@@ -15,6 +15,9 @@
<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
</div>
</div>
</form>
</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-facets" hidden>
<div class="ls-facet" data-ls-facet="category" role="group" aria-label="Category"></div>
<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>
<div class="ls-facet" data-ls-facet="channel" role="group" aria-label="Channel"></div>
@@ -1474,6 +1474,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1474,6 +1474,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
return;
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 {
try {
// Step 1: Load mediasoup-client
// Step 1: Load mediasoup-client
updateStatus('Starting playback', {
updateStatus('Starting playback', {
@@ -1506,7 +1509,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -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(' | '));
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,
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
iceCandidates: transportParams.iceCandidates,
@@ -1879,14 +1882,21 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1879,14 +1882,21 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
console.log(`[Player] SFU viewer consuming ${consumedCount} track(s)`);
console.log(`[Player] SFU viewer consuming ${consumedCount} track(s)`);
} catch (err) {
} 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);
console.error('[Player] SFU viewer setup failed:', err);
// Close the transport explicitly so the server sees it as dead on the next
// 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
// 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).
// 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;
player._sfuRecvTransport = null;
}
}
- try { recvTransport.close(); } catch (_) {}
+ try { if (recvTransport) recvTransport.close(); } catch (_) {}
_totalIceFailures++;
_totalIceFailures++;
if (_totalIceFailures < MAX_ICE_FAILURES) {
if (_totalIceFailures < MAX_ICE_FAILURES) {
scheduleRewatch(2000);
scheduleRewatch(2000);
@@ -53,7 +53,11 @@ router.post('/live/:streamId', requireAuth, upload.single('thumbnail'), async (r
@@ -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
// 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
// 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).
// 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 NO_PICTURE_MS = 60 * 60 * 1000;
+const MEDIA_BUSY_MS = 5 * 60 * 1000;
const noPicture = new Map(); // `${kind}:${id}` → until (ms), oldest first
const noPicture = new Map(); // `${kind}:${id}` → until (ms), oldest first
function noPictureKnown(key) {
function noPictureKnown(key) {
const until = noPicture.get(key);
const until = noPicture.get(key);
@@ -62,9 +66,9 @@ function noPictureKnown(key) {
@@ -62,9 +66,9 @@ function noPictureKnown(key) {
noPicture.delete(key);
noPicture.delete(key);
return false;
return false;
}
}
-function rememberNoPicture(key) {
+function rememberNoPicture(key, ms = NO_PICTURE_MS) {
if (noPicture.size >= 5000) noPicture.delete(noPicture.keys().next().value);
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) {
async function generateFor(kind, id, req, res) {
@@ -80,8 +84,8 @@ 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 });
if (noPictureKnown(key)) return res.json({ thumbnail_url: null });
let out;
let out;
try { out = await media.generateThumbnail(kind, id); } catch (err) {
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 });
return res.json({ thumbnail_url: null });
}
}
const url = media.publicUrl(out?.url || meta.thumbnail_url);
const url = media.publicUrl(out?.url || meta.thumbnail_url);
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// own section active (server/web/assets.js activePage) and the router takes it as the current page; the channel, VOD
// 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
// 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
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -71,4 +71,26 @@ const inline = /<style id="ovsk-css">([\s\S]*?)<\/style>/.exec(index)[1];
@@ -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');
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');
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`);
console.log(`first-paint-stability: ${pass} checks passed`);
@@ -233,6 +233,20 @@ async function check(name, fn) {
@@ -233,6 +233,20 @@ async function check(name, fn) {
} finally { media.generateThumbnail = realGenerate; }
} 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 () => {
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`;
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';
CLIPS[200].thumbnail_url = '/t/clip-200-7.jpg';