openvibers/openvibe.live Commits

Section identity, hero layout, and a fix for hero buttons going blank while scrolling

OP OpenVibers committed 16 Sept 2026 · 1d978e6646 · 7 files, +183 −48

Hero buttons could lose their text and icon entirely for a frame or two during a fast scroll on
a phone. The animated sweep sat at z-index -1 and the hover ring at -2 inside a button with
`isolation: isolate`, and each of the six kept a permanent `will-change: transform` — six extra
compositor layers whose sweep composited while the content behind them did not. Both
pseudo-elements sit at z-index 0 now with the label and icon explicitly above, and the layer
promotion only exists while the animation is actually running.

Section headers were all identical, so scrolling past VODs, clips and pastes read as one endless
list with no way to tell where you were. Each section now has its own colour: a tinted tile round
the icon, the title tinted to match, a one-word kicker naming what the section is, and a rule in
the same colour running to the edge and fading. Ten sections, ten hues. Count badges sit against
their title instead of marooned across the page, and headers that carry their own sentence clip
it rather than overflowing the row.

The philosophy and legal links moved back into the hero, between the buttons and the numbers,
styled as a footnote rather than a third row of buttons.

Live-update work continued:
  - Back and Forward now run the full route teardown. It only ever ran inside navigate(), so
    every Back press left the chat socket bound to the previous channel, the coin heartbeat
    crediting watch time on a dead page, the canvas socket and its timer, and the broadcast
    desk's five polls running for the rest of the session. Verified: sockets close and the timer
    count stays flat across repeated round trips.
  - Exposed stoppers for the two loops that had none: the AI-viewer log/status polls and the
    player stats gauge, which ran at 1Hz forever once opened.
  - The live grid poll now skips a background tab, matching the section refresh.
  - Cache-Control on the three hottest polled endpoints: the public stream list (every open tab,
    every 12s), the channel live-state poll, and the voice-channel lobby. All well under their
    poll periods so nothing can look stale for a full cycle.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex a3f3702f..a2683acc 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -35,9 +35,15 @@
/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and a
small per-button offset makes a single highlight travel across the row. */
.hb { --hb-wave: 5s; --hb-step: 0.15s; }
++/* Layering note. These sat at z-index -1 and -2 inside an `isolation: isolate` button, with a
++ permanent `will-change: transform` promoting each of six buttons to its own compositor layer.
++ That combination could lose the button's text and icon entirely for a frame or two during a
++ fast scroll on a phone — the sweep layer composited, the content did not. Both pseudo-elements
++ now sit at z-index 0 with the content explicitly above them, and the layer promotion only
++ exists while the animation is actually running. */
.hb::before {
-- content: ''; position: absolute; top: 0; bottom: 0; left: -65%; width: 55%; z-index: -1;
-- pointer-events: none; will-change: transform;
++ content: ''; position: absolute; top: 0; bottom: 0; left: -65%; width: 55%; z-index: 0;
++ pointer-events: none;
background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
animation: hbWave var(--hb-wave) linear infinite;
animation-delay: calc(var(--i, 0) * var(--hb-step));
@@@ -45,10 +51,10 @@
}
/* home-fx.js adds .fx-live when the row scrolls into view and restarts it, so the wave always
plays when someone arrives at the buttons instead of running unseen on a page-load clock. */
--#page-home .hero-cta.fx-live .hb::before { animation-play-state: running; }
++#page-home .hero-cta.fx-live .hb::before { animation-play-state: running; will-change: transform; }
#page-home .hero-cta.fx-live .hb-ico { animation-play-state: running; }
.hb::after {
-- content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
++ content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: 0; padding: 1px;
background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s;
@@@ -63,6 +69,7 @@
animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
animation-play-state: paused;
}
++.hb-ico, .hb-label, .hb-go { position: relative; z-index: 1; }
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
.hb-go { width: 0; opacity: 0; font-size: 0.72rem; color: var(--c); transition: width 0.22s, opacity 0.22s, transform 0.22s; transform: translateX(-4px); }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 02dc1357..10661f6a 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14876,3 +14876,82 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* Narrowest screens wrap their labels, so the board is taller there, not shorter. Stated last
so it beats the wider max-width rules above. */
@media (max-width: 380px) { .hero-stats { min-height: 224px; } }
++
++/* ── Section headers ─────────────────────────────────────────────────────────
++ Every section on the home page used to open with the same small grey heading, so scrolling
++ past VODs, clips and pastes felt like scrolling through one endless list — there was nothing
++ to tell you where you were. Each section now carries its own colour, set as --sc on the
++ header: the icon sits in a tinted tile, the title is tinted to match, a one-word kicker says
++ what the section is, and a rule in the same colour runs out to the edge and fades.
++
++ Falls back to the theme accent when --sc is not set, so headers on other pages are unchanged
++ apart from the new shape. */
++.section-header {
++ --sc: var(--accent);
++ display: flex; align-items: center; gap: 11px;
++ margin: 34px 0 14px; justify-content: flex-start; flex-wrap: nowrap;
++ position: relative;
++}
++.section-header h2 {
++ display: flex; align-items: center; gap: 10px; flex: none;
++ font-size: clamp(1.05rem, 2.6vw, 1.32rem); font-weight: 850; letter-spacing: -0.02em;
++ color: color-mix(in srgb, var(--sc) 58%, var(--text-primary));
++ margin: 0; white-space: nowrap;
++}
++/* The icon gets a tile so the colour reads as a label, not as decoration on a letter. */
++.section-header h2 > i:first-child {
++ width: 30px; height: 30px; flex: none; border-radius: 10px;
++ display: grid; place-items: center; font-size: 0.82rem;
++ color: var(--sc);
++ background: color-mix(in srgb, var(--sc) 15%, transparent);
++ border: 1px solid color-mix(in srgb, var(--sc) 34%, transparent);
++ box-shadow: 0 0 16px color-mix(in srgb, var(--sc) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);
++}
++/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. */
++.section-header h2 > i.live-dot {
++ width: auto; height: auto; background: none; border: 0; box-shadow: none;
++ color: var(--live-red); font-size: 0.62rem;
++}
++.section-kicker {
++ font-size: 0.6rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase;
++ color: color-mix(in srgb, var(--sc) 62%, var(--text-muted));
++ padding: 3px 8px; border-radius: 999px; align-self: center;
++ background: color-mix(in srgb, var(--sc) 10%, transparent);
++ border: 1px solid color-mix(in srgb, var(--sc) 22%, transparent);
++ white-space: nowrap;
++}
++/* A rule that carries the section colour out across the page and fades. */
++.section-header::after {
++ content: ''; flex: 1 1 auto; height: 1px; min-width: 18px; align-self: center;
++ background: linear-gradient(90deg, color-mix(in srgb, var(--sc) 46%, transparent), transparent 88%);
++}
++/* Links like "View All" belong at the far right; a count badge belongs against the title it
++ counts, not marooned on the other side of the page. */
++.section-header > :not(h2) { flex: none; order: 3; margin-left: 10px; min-width: 0; }
++.section-header > .badge { order: 1; margin-left: 0; }
++.section-header .badge { background: var(--sc); }
++/* Some headers carry a sentence of their own (the star spotlight does). Let it take the slack
++ the rule would have used, and clip rather than overflow the row. */
++.section-header > span:not(.badge), .section-header > p, .section-header > .muted {
++ flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
++ font-size: 0.78rem; color: var(--text-muted);
++}
++
++@media (max-width: 560px) {
++ .section-header { gap: 8px; margin: 26px 0 12px; }
++ .section-header h2 { gap: 8px; }
++ .section-header h2 > i:first-child { width: 26px; height: 26px; border-radius: 9px; font-size: 0.74rem; }
++ .section-kicker { display: none; }
++}
++
++
++/* The philosophy + legal row sits between the hero buttons and the stat board now, so it needs
++ to read as a quiet footnote rather than a third row of buttons competing with them. */
++#page-home .hero-philosophy-links { margin: 10px auto 12px; gap: 8px 10px; flex-wrap: wrap; justify-content: center; }
++#page-home .hero-philosophy-btn { font-size: 0.8rem; padding: 6px 13px; }
++#page-home .hero-philosophy-link { font-size: 0.78rem; }
++@media (max-width: 480px) {
++ #page-home .hero-philosophy-links { margin: 8px auto 10px; gap: 6px 8px; }
++ #page-home .hero-philosophy-btn { font-size: 0.74rem; padding: 5px 11px; }
++ #page-home .hero-philosophy-link { font-size: 0.72rem; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 0091e6e0..bac17714 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,8 +88,8 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=173">
-- <link rel="stylesheet" href="/css/home-fx.css?v=10">
++ <link rel="stylesheet" href="/css/style.css?v=176">
++ <link rel="stylesheet" href="/css/home-fx.css?v=11">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -297,14 +297,24 @@
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build &amp; Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
++ <div class="hero-philosophy-links">
++ <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
++ <i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
++ </a>
++ <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
++ </div>
<div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
</div>
</div>
<div class="container">
<!-- Live Streams -->
-- <div class="section-header">
-- <h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
++ <div class="section-header" style="--sc:#ef4444">
++ <h2><i class="fa-solid fa-circle live-dot"></i> Live Now<small class="section-kicker">on air</small></h2>
<span id="live-count" class="badge">0</span>
</div>
<div class="stream-grid" id="stream-grid-live">
@@@ -360,16 +370,16 @@
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
-- <div class="section-header">
-- <h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
++ <div class="section-header" style="--sc:#f59e0b">
++ <h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
</div>
<div class="pulse-grid" id="pulse-grid"></div>
</div>
<!-- AI Moments showcase -->
<div class="home-section-lazy" id="home-moments-section" style="display:none">
-- <div class="section-header">
-- <h2><i class="fa-solid fa-brain"></i> AI Moments</h2>
++ <div class="section-header" style="--sc:#22d3ee">
++ <h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
<span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
</div>
<div class="moments-row" id="home-moments-row"></div>
@@@ -535,8 +545,8 @@
<div class="container">
<!-- Recently Online -->
-- <div class="section-header">
-- <h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online</h2>
++ <div class="section-header" style="--sc:#60a5fa">
++ <h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online<small class="section-kicker">just missed them</small></h2>
</div>
<div class="stream-grid" id="stream-grid-recent">
<div class="empty-state">
@@@ -546,39 +556,39 @@
<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent VODs -->
-- <div class="section-header" id="home-recent-vods-header" style="display:none">
-- <h2><i class="fa-solid fa-video"></i> Recent VODs</h2>
++ <div class="section-header" style="--sc:#a78bfa" id="home-recent-vods-header" style="display:none">
++ <h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Clips -->
-- <div class="section-header" id="home-clips-header" style="display:none">
-- <h2><i class="fa-solid fa-scissors"></i> Recent Clips</h2>
++ <div class="section-header" style="--sc:#f472b6" id="home-clips-header" style="display:none">
++ <h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Pastes -->
-- <div class="section-header" id="home-pastes-header" style="display:none">
-- <h2><i class="fa-solid fa-paste"></i> Recent Pastes</h2>
++ <div class="section-header" style="--sc:#2dd4bf" id="home-pastes-header" style="display:none">
++ <h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Scraplandia Leaderboards -->
-- <div class="section-header" id="home-quest-header" style="display:none">
-- <h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards</h2>
++ <div class="section-header" style="--sc:#fbbf24" id="home-quest-header" style="display:none">
++ <h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>
<a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>
</div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<!-- Canvas Preview -->
-- <div class="section-header" id="home-canvas-header" style="display:none">
-- <h2><i class="fa-solid fa-palette"></i> Community Canvas</h2>
++ <div class="section-header" style="--sc:#4ade80" id="home-canvas-header" style="display:none">
++ <h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>
<a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>
</div>
<div class="home-canvas-preview" id="home-canvas-preview"></div>
@@@ -716,8 +726,8 @@
</div>
<!-- OpenVibe.Live Changelog -->
-- <div class="section-header" style="margin-top:32px;">
-- <h2><i class="fa-solid fa-code-commit"></i> Recent Changes</h2>
++ <div class="section-header" style="--sc:#94a3b8" style="margin-top:32px;">
++ <h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
<div style="display:flex;gap:8px;align-items:center;">
<button id="home-changelog-toggle" class="badge" onclick="toggleHomeChangelog()" style="cursor:pointer;border:none;background:var(--surface);padding:4px 10px;border-radius:6px;color:var(--text);font-size:0.78rem;">Show All</button>
<a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="badge" style="cursor:pointer;text-decoration:none;">View All</a>
@@@ -730,20 +740,11 @@
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</div>
-- <!-- The quiet bits. These used to sit in the hero, above every stream on the site;
-- they belong under the content, not in front of it. -->
++ <!-- Newest shipped commit — built in the open, receipts included. It belongs under the
++ content, not in front of every stream on the site. -->
<!-- Newest shipped commit — built in the open, receipts included. -->
<a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
-- <div class="hero-philosophy-links">
-- <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
-- <i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
-- </a>
-- <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
-- <span aria-hidden="true">&bull;</span>
-- <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
-- <span aria-hidden="true">&bull;</span>
-- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
-- </div>
++
</div>
</section>
@@@ -5129,8 +5130,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
--<script src="/js/app.js?v=148" defer></script>
--<script src="/js/stream-player.js?v=21" defer></script>
++<script src="/js/app.js?v=149" defer></script>
++<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
@@@ -5200,7 +5201,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
--<script src="/js/ai-viewers.js?v=2" defer></script>
++<script src="/js/ai-viewers.js?v=3" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/context-cards.js?v=3" defer></script>
ddiff --git a/public/js/ai-viewers.js b/public/js/ai-viewers.js
iindex 7b15bd7e..7b1ffcee 100644
---- a/public/js/ai-viewers.js
++++ b/public/js/ai-viewers.js
@@@ -342,4 +342,7 @@
} catch (e) { container.innerHTML = `<span class="muted">AI viewers unavailable: ${esc(e.message)}</span>`; }
};
window.aivSetSlot = function aivSetSlot(msId, on) { aivSet('slots', { [String(msId)]: !!on }); };
++ // Route teardown needs a way to stop these: the log and status polls (5s and 10s) used to
++ // keep running for the rest of the session once you left the dashboard.
++ window.stopAiViewerActivity = stopActivity;
})();
ddiff --git a/public/js/app.js b/public/js/app.js
iindex eb4d9369..099c0290 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -895,10 +895,17 @@ function dashNav(event) {
}
/* ── SPA Router (URL-based) ───────────────────────────────────── */
--function navigate(urlPath, replace = false) {
-- closeMobileNav();
--
-- // Clean up existing page state (destroy player, disconnect chat, etc.)
++/**
++ * Everything that must stop when we leave a route.
++ *
++ * This used to live inside navigate(), which meant the browser's Back and Forward buttons —
++ * which go through popstate → routeFromURL() and never touch navigate() — left everything
++ * running: the chat WebSocket still bound to the previous channel, the coin heartbeat still
++ * crediting watch time on a page with nothing playing, the canvas socket and its timer, the
++ * global AI poll, the arena and AI-viewer loops, and the broadcast desk's five polls. Every Back
++ * press added another set. Now both paths go through here.
++ */
++function teardownRoute(nextPath) {
if (typeof destroyPlayer === 'function') destroyPlayer();
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyCanvasPage === 'function') destroyCanvasPage();
@@@ -908,7 +915,6 @@ function navigate(urlPath, replace = false) {
if (typeof stopStreamStatusPoll === 'function') stopStreamStatusPoll();
clearInterval(uptimeInterval);
-- // Clean up live VOD poll timer
if (window._liveVodPollTimer) {
clearInterval(window._liveVodPollTimer);
window._liveVodPollTimer = null;
@@@ -919,6 +925,26 @@ function navigate(urlPath, replace = false) {
window._globalAiPollTimer = null;
}
++ // Loops other modules own. Each is optional — a module that isn't loaded simply isn't stopped.
++ const stop = (fn) => { try { if (typeof window[fn] === 'function') window[fn](); } catch { /* */ } };
++ stop('_aStopTimers');
++ stop('stopAiViewerActivity');
++ stop('stopPlayerStatsPoll');
++ // The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
++ // costs roughly a request a second for the rest of the session.
++ const goingToBroadcast = typeof nextPath === 'string' && nextPath.startsWith('/broadcast');
++ if (!goingToBroadcast) {
++ stop('stopRtmpStatusPoll');
++ stop('stopRtmpPreview');
++ stop('stopRestreamStatusPolling');
++ stop('_stopMediaPipPoll');
++ }
++}
++
++function navigate(urlPath, replace = false) {
++ closeMobileNav();
++ teardownRoute(urlPath);
++
// Normalize path
if (!urlPath.startsWith('/')) urlPath = '/' + urlPath;
@@@ -947,8 +973,10 @@ function routeFromURL() {
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
-- // Clean up existing page state
-- if (typeof destroyPlayer === 'function') destroyPlayer();
++ // Full teardown, not just the player. navigate() has already run this for in-app links; on a
++ // Back/Forward press this is the only place it happens, and running it twice is harmless
++ // because every stopper is idempotent.
++ teardownRoute(path);
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
@@@ -2572,6 +2600,9 @@ function _initUptimeTooltip() {
// animate out ended ones — no full re-render, no flashing.
async function refreshHomeLive() {
if (!_homeIsActive()) return;
++ // A background tab has nobody to show a refreshed grid to. refreshHomeSections() already
++ // skips in that case; this one polled /api/streams forever regardless.
++ if (document.hidden) return;
let streams;
try { const d = await api('/streams'); streams = d.streams || []; } catch { return; }
const grid = document.getElementById('stream-grid-live');
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex c9db7f9f..7aab6ea8 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -3875,6 +3875,14 @@ function showUnmuteOverlay(video) {
bitrate, resolution, fps and loss — measured live from the video element
and (for WebRTC/SFU) RTCPeerConnection/Transport getStats(). */
let _statsPoll = null;
++/**
++ * The stats gauge polls getStats() once a second and rewrites 18 nodes. destroyPlayer() never
++ * cleared it, so opening the gauge once and then navigating away left it running — holding a
++ * dead player closure — for the rest of the session.
++ */
++window.stopPlayerStatsPoll = function stopPlayerStatsPoll() {
++ if (_statsPoll) { clearInterval(_statsPoll); _statsPoll = null; }
++};
let _statsPrev = null; // { id, ts, bytes } for the chosen video inbound-rtp
let _statsPrevFrames = null; // totalVideoFrames for the HLS/JSMPEG fps fallback
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 97a255b4..1c0b408b 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -572,6 +572,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
// Returns ONLY the data needed to start the player — no VODs, clips, or heavy queries
router.get('/channel/:username/live', (req, res) => {
try {
++ res.set('Cache-Control', 'public, max-age=3');
const channel = db.getChannelByUsername(req.params.username);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
@@@ -1050,6 +1051,10 @@ router.get('/channel/:username/weather', async (req, res) => {
// ── List Live Streams ────────────────────────────────────────
router.get('/', optionalAuth, (req, res) => {
try {
++ // Every open home tab polls this every 12 seconds and the response is the same for
++ // everyone, so let the browser and the edge answer most of those hits. Kept well under
++ // the poll period so a card can never look stale for a whole cycle.
++ res.set('Cache-Control', 'public, max-age=5');
const restreamManager = require('./restream-manager');
const streams = db.getLiveStreams();
const channelMap = db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
@@@ -1196,6 +1201,7 @@ router.get('/recent-vods', async (req, res) => {
router.get('/voice-channels', (req, res) => {
try {
++ res.set('Cache-Control', 'public, max-age=2');
res.json({ channels: callServer.listChannels() });
} catch (err) {
console.error('[Streaming]', err.message);