openvibers/openvibe.live Commits
Section identity, hero layout, and a fix for hero buttons going blank while scrolling
Changes
@@@ -35,9 +35,15 @@/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and asmall 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 alwaysplays 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); }@@@ -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 lastso 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; }++}@@@ -88,8 +88,8 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat 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 — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & 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">•</span>++ <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>++ <span aria-hidden="true">•</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">•</span>-- <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>-- <span aria-hidden="true">•</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>@@@ -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;})();@@@ -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 timerif (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 pathif (!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 pagesdocument.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');@@@ -3875,6 +3875,14 @@ function showUnmuteOverlay(video) {bitrate, resolution, fps and loss — measured live from the video elementand (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-rtplet _statsPrevFrames = null; // totalVideoFrames for the HLS/JSMPEG fps fallback@@@ -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 queriesrouter.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);
@@ -35,9 +35,15 @@
@@ -35,9 +35,15 @@
/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and a
/* 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. */
small per-button offset makes a single highlight travel across the row. */
.hb { --hb-wave: 5s; --hb-step: 0.15s; }
.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 {
.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);
background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
animation: hbWave var(--hb-wave) linear infinite;
animation: hbWave var(--hb-wave) linear infinite;
animation-delay: calc(var(--i, 0) * var(--hb-step));
animation-delay: calc(var(--i, 0) * var(--hb-step));
@@ -45,10 +51,10 @@
@@ -45,10 +51,10 @@
}
}
/* home-fx.js adds .fx-live when the row scrolls into view and restarts it, so the wave always
/* 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. */
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; }
#page-home .hero-cta.fx-live .hb-ico { animation-play-state: running; }
.hb::after {
.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%);
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: 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;
-webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s;
@@ -63,6 +69,7 @@
@@ -63,6 +69,7 @@
animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
animation-play-state: paused;
animation-play-state: paused;
}
}
+.hb-ico, .hb-label, .hb-go { position: relative; z-index: 1; }
.hb-label { flex: 1; text-align: left; }
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
/* 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); }
.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); }
@@ -14876,3 +14876,82 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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
/* Narrowest screens wrap their labels, so the board is taller there, not shorter. Stated last
so it beats the wider max-width rules above. */
so it beats the wider max-width rules above. */
@media (max-width: 380px) { .hero-stats { min-height: 224px; } }
@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; }
+}
@@ -88,8 +88,8 @@
@@ -88,8 +88,8 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
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. -->
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="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/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -297,14 +297,24 @@
@@ -297,14 +297,24 @@
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
data-ovtip-foot="<i class="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
</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">•</span>
+ <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
+ <span aria-hidden="true">•</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 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>
</div>
<div class="container">
<div class="container">
<!-- Live Streams -->
<!-- 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>
<span id="live-count" class="badge">0</span>
</div>
</div>
<div class="stream-grid" id="stream-grid-live">
<div class="stream-grid" id="stream-grid-live">
@@ -360,16 +370,16 @@
@@ -360,16 +370,16 @@
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<div class="home-section-lazy" id="home-pulse-section" style="display:none">
<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>
<div class="pulse-grid" id="pulse-grid"></div>
<div class="pulse-grid" id="pulse-grid"></div>
</div>
</div>
<!-- AI Moments showcase -->
<!-- AI Moments showcase -->
<div class="home-section-lazy" id="home-moments-section" style="display:none">
<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>
<span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
</div>
</div>
<div class="moments-row" id="home-moments-row"></div>
<div class="moments-row" id="home-moments-row"></div>
@@ -535,8 +545,8 @@
@@ -535,8 +545,8 @@
<div class="container">
<div class="container">
<!-- Recently Online -->
<!-- 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>
<div class="stream-grid" id="stream-grid-recent">
<div class="stream-grid" id="stream-grid-recent">
<div class="empty-state">
<div class="empty-state">
@@ -546,39 +556,39 @@
@@ -546,39 +556,39 @@
<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent VODs -->
<!-- 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>
<a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Clips -->
<!-- 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>
<a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></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>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Pastes -->
<!-- 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>
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Scraplandia Leaderboards -->
<!-- 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>
<a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>
</div>
</div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<!-- Canvas Preview -->
<!-- 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>
<a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>
</div>
</div>
<div class="home-canvas-preview" id="home-canvas-preview"></div>
<div class="home-canvas-preview" id="home-canvas-preview"></div>
@@ -716,8 +726,8 @@
@@ -716,8 +726,8 @@
</div>
</div>
<!-- OpenVibe.Live Changelog -->
<!-- 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;">
<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>
<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>
<a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="badge" style="cursor:pointer;text-decoration:none;">View All</a>
@@ -730,20 +740,11 @@
@@ -730,20 +740,11 @@
<div class="home-changelog-fade" id="home-changelog-fade"></div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</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. -->
<!-- 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>
<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">•</span>
- <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
- <span aria-hidden="true">•</span>
- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
- </div>
+
</div>
</div>
</section>
</section>
@@ -5129,8 +5130,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" 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/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/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" 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
@@ -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/settings.js?v=12" defer></script>
<script src="/js/staff-console.js?v=1" 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/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/arena.js?v=18" defer></script>
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/context-cards.js?v=3" defer></script>
<script src="/js/context-cards.js?v=3" defer></script>
@@ -342,4 +342,7 @@
@@ -342,4 +342,7 @@
} catch (e) { container.innerHTML = `<span class="muted">AI viewers unavailable: ${esc(e.message)}</span>`; }
} 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 }); };
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;
})();
})();
@@ -895,10 +895,17 @@ function dashNav(event) {
@@ -895,10 +895,17 @@ function dashNav(event) {
}
}
/* ── SPA Router (URL-based) ───────────────────────────────────── */
/* ── 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 destroyPlayer === 'function') destroyPlayer();
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyCanvasPage === 'function') destroyCanvasPage();
if (typeof destroyCanvasPage === 'function') destroyCanvasPage();
@@ -908,7 +915,6 @@ function navigate(urlPath, replace = false) {
@@ -908,7 +915,6 @@ function navigate(urlPath, replace = false) {
if (typeof stopStreamStatusPoll === 'function') stopStreamStatusPoll();
if (typeof stopStreamStatusPoll === 'function') stopStreamStatusPoll();
clearInterval(uptimeInterval);
clearInterval(uptimeInterval);
- // Clean up live VOD poll timer
if (window._liveVodPollTimer) {
if (window._liveVodPollTimer) {
clearInterval(window._liveVodPollTimer);
clearInterval(window._liveVodPollTimer);
window._liveVodPollTimer = null;
window._liveVodPollTimer = null;
@@ -919,6 +925,26 @@ function navigate(urlPath, replace = false) {
@@ -919,6 +925,26 @@ function navigate(urlPath, replace = false) {
window._globalAiPollTimer = null;
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
// Normalize path
if (!urlPath.startsWith('/')) urlPath = '/' + urlPath;
if (!urlPath.startsWith('/')) urlPath = '/' + urlPath;
@@ -947,8 +973,10 @@ function routeFromURL() {
@@ -947,8 +973,10 @@ function routeFromURL() {
const path = window.location.pathname;
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
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
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
@@ -2572,6 +2600,9 @@ function _initUptimeTooltip() {
@@ -2572,6 +2600,9 @@ function _initUptimeTooltip() {
// animate out ended ones — no full re-render, no flashing.
// animate out ended ones — no full re-render, no flashing.
async function refreshHomeLive() {
async function refreshHomeLive() {
if (!_homeIsActive()) return;
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;
let streams;
try { const d = await api('/streams'); streams = d.streams || []; } catch { return; }
try { const d = await api('/streams'); streams = d.streams || []; } catch { return; }
const grid = document.getElementById('stream-grid-live');
const grid = document.getElementById('stream-grid-live');
@@ -3875,6 +3875,14 @@ function showUnmuteOverlay(video) {
@@ -3875,6 +3875,14 @@ function showUnmuteOverlay(video) {
bitrate, resolution, fps and loss — measured live from the video element
bitrate, resolution, fps and loss — measured live from the video element
and (for WebRTC/SFU) RTCPeerConnection/Transport getStats(). */
and (for WebRTC/SFU) RTCPeerConnection/Transport getStats(). */
let _statsPoll = null;
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 _statsPrev = null; // { id, ts, bytes } for the chosen video inbound-rtp
let _statsPrevFrames = null; // totalVideoFrames for the HLS/JSMPEG fps fallback
let _statsPrevFrames = null; // totalVideoFrames for the HLS/JSMPEG fps fallback
@@ -572,6 +572,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
@@ -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
// Returns ONLY the data needed to start the player — no VODs, clips, or heavy queries
router.get('/channel/:username/live', (req, res) => {
router.get('/channel/:username/live', (req, res) => {
try {
try {
+ res.set('Cache-Control', 'public, max-age=3');
const channel = db.getChannelByUsername(req.params.username);
const channel = db.getChannelByUsername(req.params.username);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
@@ -1050,6 +1051,10 @@ router.get('/channel/:username/weather', async (req, res) => {
@@ -1050,6 +1051,10 @@ router.get('/channel/:username/weather', async (req, res) => {
// ── List Live Streams ────────────────────────────────────────
// ── List Live Streams ────────────────────────────────────────
router.get('/', optionalAuth, (req, res) => {
router.get('/', optionalAuth, (req, res) => {
try {
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 restreamManager = require('./restream-manager');
const streams = db.getLiveStreams();
const streams = db.getLiveStreams();
const channelMap = db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
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) => {
@@ -1196,6 +1201,7 @@ router.get('/recent-vods', async (req, res) => {
router.get('/voice-channels', (req, res) => {
router.get('/voice-channels', (req, res) => {
try {
try {
+ res.set('Cache-Control', 'public, max-age=2');
res.json({ channels: callServer.listChannels() });
res.json({ channels: callServer.listChannels() });
} catch (err) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);