openvibers/openvibe.live Commits
Performance: cache versioned assets, skeleton the feeds, cache prepared statements, index hot paths
Changes
@@@ -156,3 +156,34 @@Below-the-fold sections are skipped by layout and paint until they approach the viewport.contain-intrinsic-size keeps the scrollbar honest so the page doesn't jump as they resolve. */#page-home .home-section-lazy { content-visibility: auto; contain-intrinsic-size: auto 480px; }++++/* ── "Build & Mod": the one hero button that asks for something back ──++ Everything else in this row sends you somewhere to watch. This one says the site is yours to++ extend, so it is allowed to be the loudest: a drifting gradient wash, a sparkle that pops off++ the wand, and a secondary "API docs" label that keeps the destination honest without the++ button reading like documentation. All of it pauses with the rest of the row when off-screen. */++.hb-label small { font-size: 0.68rem; font-weight: 600; opacity: 0.62; margin-left: 5px; letter-spacing: 0.01em; }++.hb-vibe {++ background-image: linear-gradient(115deg, color-mix(in srgb, var(--c) 16%, transparent), transparent 42%, color-mix(in srgb, #a78bfa 14%, transparent));++ background-size: 220% 100%;++ animation: hbVibeWash 11s ease-in-out infinite;++}++@keyframes hbVibeWash { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }++.hb-vibe .hb-ico { position: relative; overflow: visible; }++/* A spark that leaves the wand every few seconds. */++.hb-vibe .hb-ico::after {++ content: ''; position: absolute; top: 2px; right: 1px; width: 5px; height: 5px; border-radius: 50%;++ background: radial-gradient(circle, #fff, var(--c) 60%, transparent 70%);++ opacity: 0; animation: hbSpark 4.4s ease-out infinite;++}++@keyframes hbSpark {++ 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }++ 10% { opacity: 1; transform: translate(2px, -3px) scale(1); }++ 38% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }++ 100% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }++}++.hb-vibe:hover { box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--c) 55%, transparent); }++#page-home .hero-cta .hb-vibe, #page-home .hero-cta .hb-vibe .hb-ico::after { animation-play-state: paused; }++#page-home .hero-cta.fx-live .hb-vibe, #page-home .hero-cta.fx-live .hb-vibe .hb-ico::after { animation-play-state: running; }++@media (max-width: 520px) { .hb-label small { display: none; } }++@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }@@@ -61,19 +61,54 @@})();</script>-- <!-- Stylesheets -->++ <!-- Placeholder styling, inline on purpose.++ The skeletons below are in the HTML so the page reserves the space each feed will occupy++ before a single script runs. That only works if their styling is here too — a stylesheet++ request would land after the first paint, which is the moment it is needed. ~1KB. -->++ <style id="ovsk-css">++ .ovsk{position:relative;overflow:hidden;background:var(--bg-card,#171826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:14px}++ .ovsk-bar{position:relative;overflow:hidden;background:color-mix(in srgb,var(--text-muted,#8b93ad) 14%,transparent);border-radius:6px}++ .ovsk::after,.ovsk-bar::after{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,color-mix(in srgb,var(--text-primary,#f1f4fb) 7%,transparent) 50%,transparent 80%);transform:translateX(-100%);animation:ovskSweep 1.5s ease-in-out infinite}++ @keyframes ovskSweep{to{transform:translateX(100%)}}++ .ovsk-card{display:flex;flex-direction:column}++ .ovsk-thumb{aspect-ratio:16/9;width:100%;background:color-mix(in srgb,var(--text-muted,#8b93ad) 10%,transparent)}++ .ovsk-meta{display:flex;gap:10px;align-items:center;padding:10px 12px 12px}++ .ovsk-av{width:32px;height:32px;border-radius:50%;flex:none}++ .ovsk-lines{flex:1;min-width:0;display:grid;gap:6px}++ .ovsk-panel{padding:14px;display:grid;gap:10px}++ .ovsk-fade{animation:ovskFade .25s ease forwards}++ @keyframes ovskFade{to{opacity:0}}++ @media (max-width:760px){#stream-grid-live .ovsk-card:nth-of-type(n+3){display:none}}++ @media (prefers-reduced-motion:reduce){.ovsk::after,.ovsk-bar::after{animation:none}}++ </style>++++ <!-- Stylesheets.++ Blocking set = what the home page paints with. Everything else belongs to another route++ (the broadcast desk, the messenger, the cosmetics modal, after-show reports, the guide++ 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=166">-- <link rel="stylesheet" href="/css/chat-shared.css?v=1">-- <link rel="stylesheet" href="/css/broadcast.css?v=11">-- <link rel="stylesheet" href="/css/cosmetics.css?v=2">-- <link rel="stylesheet" href="/css/messenger.css?v=1">-- <link rel="stylesheet" href="/css/home-fx.css?v=9">++ <link rel="stylesheet" href="/css/home-fx.css?v=10"><link rel="stylesheet" href="/css/home-tour.css?v=4">-- <link rel="stylesheet" href="/css/ov-guide.css?v=1"><link rel="stylesheet" href="/css/i18n-star.css?v=5">-- <link rel="stylesheet" href="/css/recap.css?v=1">-- <link rel="stylesheet" href="/css/context-cards.css?v=3"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">++ <link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/context-cards.css?v=3" media="print" onload="this.media='all';this.onload=null">++ <noscript>++ <link rel="stylesheet" href="/css/chat-shared.css?v=1">++ <link rel="stylesheet" href="/css/broadcast.css?v=11">++ <link rel="stylesheet" href="/css/cosmetics.css?v=2">++ <link rel="stylesheet" href="/css/messenger.css?v=1">++ <link rel="stylesheet" href="/css/ov-guide.css?v=1">++ <link rel="stylesheet" href="/css/recap.css?v=1">++ <link rel="stylesheet" href="/css/context-cards.css?v=3">++ </noscript><!-- JSON-LD Structured Data --><script type="application/ld+json">@@@ -248,7 +283,7 @@<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate" id="hero-rotate"></span></span></p><p class="hero-quip" id="hero-quip" aria-live="polite"></p><div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>-- <div class="hero-stats" id="hero-stats" aria-label="Site stats"></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-egg" id="hero-egg" style="display:none"><button class="hero-egg-head" type="button" onclick="toggleHeroEgg()"><span class="hero-egg-icon">🥚</span>@@@ -286,12 +321,12 @@<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"-- data-ovtip-title="Built for vibe coders"++ data-ovtip-title="Mod it, bot it, build on it"data-ovtip-icon="fa-wand-magic-sparkles"data-ovtip-accent="#f472b6"data-ovtip-place="top"-- data-ovtip="Every feature here has an <b>open API</b> — chat, streams, clips, overlays, robots, the lot. <em>You don't need to know how to code.</em> Point an AI at these docs, describe the bot or overlay you want, and it writes it for you."-- data-ovtip-foot="<i class="fa-solid fa-heart"></i> Open here, good vibes — come build something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">API Docs</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ 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><!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) --><div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>@@@ -312,10 +347,10 @@<div class="container"><!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->-- <div class="home-digest" id="home-digest" style="display:none"></div>++ <div class="home-digest" id="home-digest"><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><!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->-- <div class="home-star-section" id="home-star-section" style="display:none"></div>++ <div class="home-star-section" id="home-star-section"><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 class="ovsk-bar" style="height:10px;width:80%"></div></div></div><!-- Live Streams --><div class="section-header">@@@ -323,7 +358,11 @@<span id="live-count" class="badge">0</span></div><div class="stream-grid" id="stream-grid-live">-- <div class="empty-state" id="no-live-streams">++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>++ <div class="empty-state" id="no-live-streams" style="display:none"><i class="fa-solid fa-tower-broadcast fa-3x"></i><p>No one is streaming right now</p><p class="muted">Be the first to go live!</p>@@@ -5084,7 +5123,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=142" defer></script>++<script src="/js/ov-skeleton.js?v=1" defer></script>++<script src="/js/app.js?v=144" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5094,7 +5134,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/chat-autocomplete.js?v=1" defer></script><script src="/js/channel-uploads.js?v=12" defer></script><script src="/js/controls.js?v=8" defer></script>--<script src="/js/onvif-dashboard.js?v=5" defer></script>++<script src="/js/onvif-dashboard.js?v=6" defer></script><script src="/js/dashboard.js?v=16" defer></script><script src="/js/admin.js?v=8" defer></script><!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->@@@ -5149,7 +5189,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/broadcast-devices.js?v=2" defer></script><script src="/js/broadcast-vods.js?v=2" defer></script><script src="/js/broadcast-workspace.js?v=16" defer></script>--<script src="/js/broadcast.js?v=28" defer></script>++<script src="/js/broadcast.js?v=29" defer></script><script src="/js/call.js?v=3" defer></script><script src="/js/voice-channels.js?v=2" defer></script><script src="/js/settings.js?v=12" defer></script>@@@ -5164,8 +5204,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/draggable-fab.js?v=2" defer></script><script src="/js/messenger.js?v=2" defer></script><script src="/js/ov-mark.js?v=1" defer></script>--<script src="/shared/tooltip.js" defer></script>--<script src="/shared/footer.js" defer></script>++<script src="/shared/tooltip.js?v=1" defer></script>++<script src="/shared/footer.js?v=1" defer></script><script src="/js/footer-live.js?v=1" defer></script><script src="/js/home-fx.js?v=6" defer></script><script src="/js/home-tour.js?v=6" defer></script>@@@ -5173,9 +5213,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/guides-live.js?v=4" defer></script><!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->--<script src="/shared/theme-loader.js" defer></script>++<script src="/shared/theme-loader.js?v=1" defer></script><script src="/shared/notification-ui.js?v=2" defer></script>--<script src="/shared/account-switcher.js" defer></script><script>// Initialize OpenVibe notifications and theme sync (navbar is handled natively by openvibelive).// Runs on DOMContentLoaded so the now-deferred shared libs (OpenVibeThemeLoader / OpenVibeNotifications)@@@ -1307,7 +1307,15 @@ function renderHeroStats(stats) {{ icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },{ icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },{ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },-- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },++ // OpenCoins are network-wide; channel points below are per channel. Shown only when++ // the wallet service answered — four zeroed chips would read as "nobody has any".++ ...(stats.coinsEarned != null ? [++ { icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins Earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools' },++ { icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins Spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },++ { icon: 'fa-vault', num: stats.coinsCirculating, label: 'Coins Held', title: 'OpenCoins sitting in wallets right now, across every OpenVibe site' },++ { icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },++ ] : []),++ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', recent: R.points },{ icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },],@@@ -1866,6 +1874,17 @@ async function loadHome() {void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogansvoid loadHeroEgg(); // daily AI easter egg widget++ // Claim the space every feed is about to fill. Without this the page arrives as a short++ // column that grows in jumps as each request lands, which reads as slow and shoves content++ // around under the reader's thumb. Placeholders are cleared by whoever renders real content,++ // with a backstop below so nothing shimmers forever after a failed request.++ if (window.OVSkeleton) {++ OVSkeleton.cards('stream-grid-live');++ OVSkeleton.block('home-digest', { rows: 3 });++ OVSkeleton.block('home-star-section', { rows: 4, height: 140 });++ setTimeout(() => OVSkeleton.clearAll(), 9000);++ }++// Reset homepage pagination on fresh load_homeRecentOnlinePage = 1;_homeRecentVodsPage = 1;@@@ -1879,7 +1898,13 @@ async function loadHome() {const noLiveEl = document.getElementById('no-live-streams');if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';renderStreamGrid('stream-grid-live', streams, true);-- } catch (e) { console.error('Failed to load live streams', e); }++ } catch (e) {++ console.error('Failed to load live streams', e);++ // Never leave placeholders shimmering over a failed request.++ if (window.OVSkeleton) OVSkeleton.clear('stream-grid-live');++ const noLiveEl = document.getElementById('no-live-streams');++ if (noLiveEl) noLiveEl.style.display = '';++ }loadHomeRecentOnline();void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update@@@ -1977,7 +2002,8 @@ async function loadHomeDigest() {try { since = localStorage.getItem(KEY); } catch { /* */ }try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }let d;-- try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); } catch { return; }++ try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }++ catch { box.innerHTML = ''; box.style.display = 'none'; return; }if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }const away = !!(since && currentUser);const n = (v) => Number(v || 0).toLocaleString();@@@ -2305,11 +2331,20 @@ function streamCardHTML(s, isLive) {function renderStreamGrid(containerId, streams, isLive) {const c = document.getElementById(containerId);++ if (!c) return;++ // Placeholders have done their job the moment real data lands. Remove them by class rather++ // than clearing the container, because the live grid keeps its empty-state child in there.++ c.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } });if (!streams.length) {if (!isLive) c.innerHTML = '<div class="empty-state"><p class="muted">No recent streams</p></div>';return;}++ // Keep the live grid's empty-state node across a re-render. Replacing innerHTML used to++ // delete it outright, so once anyone went live the "nobody is streaming" message could never++ // come back when they all stopped — the grid just sat there blank.++ const empty = c.querySelector('.empty-state');c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');++ if (empty) { empty.style.display = 'none'; c.appendChild(empty); }}/* ── Real-time home updates ─────────────────────────────────── */@@@ -5753,7 +5753,19 @@ function _initPipDragResize() {document.addEventListener('DOMContentLoaded', () => {initBroadcastSettingsListeners();-- initBroadcastTabs();++ // Restoring the saved tab can issue broadcast API calls and force a layout on a hidden page.++ // Only do it once the broadcast desk is the route the visitor is actually on.++ const _bcPage = document.getElementById('page-broadcast');++ const _bcTabsOnce = () => {++ if (!_bcPage || !_bcPage.classList.contains('active')) return;++ if (_bcPage.dataset.tabsInit) return;++ _bcPage.dataset.tabsInit = '1';++ initBroadcastTabs();++ };++ _bcTabsOnce();++ if (_bcPage && 'MutationObserver' in window) {++ new MutationObserver(_bcTabsOnce).observe(_bcPage, { attributes: true, attributeFilter: ['class'] });++ }// Update mobile chat FAB visibility on resizewindow.addEventListener('resize', () => {@@@ -352,9 +352,20 @@ async function showEditCameraModal(cameraId) {}}--// Initialize on page load++// Every page's markup lives in one document, so #dash-cameras-list exists even on the home page —++// this used to fire an ONVIF camera query for anyone landing on the front page. Load the cameras++// when the dashboard is actually on screen, and again whenever it becomes so.++function _onvifMaybeLoad() {++ const page = document.getElementById('page-dashboard');++ if (!page || !page.classList.contains('active')) return;++ if (page.dataset.onvifLoaded) return;++ page.dataset.onvifLoaded = '1';++ if (document.getElementById('dash-cameras-list')) loadDashboardCameras();++}document.addEventListener('DOMContentLoaded', () => {-- if (document.getElementById('dash-cameras-list')) {-- loadDashboardCameras();++ _onvifMaybeLoad();++ const page = document.getElementById('page-dashboard');++ if (page && 'MutationObserver' in window) {++ new MutationObserver(_onvifMaybeLoad).observe(page, { attributes: true, attributeFilter: ['class'] });}});nnew file mode 100644@@@ -0,0 +1,112 @@++/**++ * ov-skeleton.js — placeholder shapes while a section is loading.++ *++ * Every feed on this site used to be `display:none` until its fetch came back, so the page++ * arrived as a short column that grew in jumps as each request landed. That reads as slow even++ * when it isn't, and every late section shoves the page around under the reader's thumb.++ *++ * A skeleton fixes both: the space a section is about to occupy is claimed immediately with++ * shimmering blocks in roughly the shape of the real thing, so the layout stops moving and the++ * page looks like it is filling in rather than stalling.++ *++ * Deliberately small and dependency-free:++ * OVSkeleton.cards('stream-grid-live') — a responsive row of stream-card placeholders++ * OVSkeleton.block(el, { rows, height }) — a generic panel placeholder++ * OVSkeleton.clear(el) — remove placeholders from a container++ *++ * Counts follow the container's real width, so a phone gets two placeholders and a wide desktop++ * gets six. There is no point rendering eight shimmering cards into a 400px column.++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OVSkeleton) return;++++ const CSS = `++.ovsk { position: relative; overflow: hidden; background: var(--bg-card, #171826);++ border: 1px solid var(--border, rgba(255,255,255,.08)); border-radius: 14px; }++.ovsk-bar { background: color-mix(in srgb, var(--text-muted, #8b93ad) 14%, transparent); border-radius: 6px; }++/* One sweep definition, shared by every placeholder, so a screen full of them moves as one. */++.ovsk::after, .ovsk-bar::after { content: ''; position: absolute; inset: 0;++ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--text-primary, #f1f4fb) 7%, transparent) 50%, transparent 80%);++ transform: translateX(-100%); animation: ovskSweep 1.5s ease-in-out infinite; }++.ovsk-bar { position: relative; overflow: hidden; }++@keyframes ovskSweep { to { transform: translateX(100%); } }++.ovsk-card { display: flex; flex-direction: column; }++.ovsk-thumb { aspect-ratio: 16 / 9; width: 100%; background: color-mix(in srgb, var(--text-muted, #8b93ad) 10%, transparent); }++.ovsk-meta { display: flex; gap: 10px; align-items: center; padding: 10px 12px 12px; }++.ovsk-av { width: 32px; height: 32px; border-radius: 50%; flex: none; }++.ovsk-lines { flex: 1; min-width: 0; display: grid; gap: 6px; }++.ovsk-panel { padding: 14px; display: grid; gap: 10px; }++.ovsk-fade { animation: ovskFade .25s ease forwards; }++@keyframes ovskFade { to { opacity: 0; } }++@media (prefers-reduced-motion: reduce) { .ovsk::after, .ovsk-bar::after { animation: none; } }`;++++ function ensure() {++ if (document.getElementById('ovsk-css')) return;++ const st = document.createElement('style'); st.id = 'ovsk-css'; st.textContent = CSS;++ document.head.appendChild(st);++ }++++ const el = (target) => typeof target === 'string' ? document.getElementById(target) : target;++++ /** How many placeholders actually fit — never more than the container can show. */++ function fitCount(container, minWidth, max) {++ const w = container.getBoundingClientRect().width || window.innerWidth;++ const perRow = Math.max(1, Math.floor(w / minWidth));++ // Two rows on a roomy screen, one on a phone, capped so we never paint a wall of grey.++ return Math.min(max, perRow * (w >= 900 ? 2 : 1));++ }++++ function cards(target, opts = {}) {++ const c = el(target);++ if (!c || c.querySelector('.ovsk-card')) return;++ ensure();++ const n = opts.count || fitCount(c, opts.minWidth || 300, opts.max || 6);++ const frag = document.createDocumentFragment();++ for (let i = 0; i < n; i++) {++ const d = document.createElement('div');++ d.className = 'ovsk ovsk-card';++ d.setAttribute('aria-hidden', 'true');++ d.innerHTML = '<div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div>'++ + '<div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div>'++ + '<div class="ovsk-bar" style="height:9px;width:45%"></div></div></div>';++ frag.appendChild(d);++ }++ c.appendChild(frag);++ }++++ /** A generic panel placeholder for sections that are not card grids. */++ function block(target, opts = {}) {++ const c = el(target);++ if (!c || c.querySelector('.ovsk-panel')) return;++ ensure();++ const rows = opts.rows || 3;++ const d = document.createElement('div');++ d.className = 'ovsk ovsk-panel';++ d.setAttribute('aria-hidden', 'true');++ if (opts.height) d.style.minHeight = typeof opts.height === 'number' ? `${opts.height}px` : opts.height;++ let html = '';++ for (let i = 0; i < rows; i++) {++ const w = [92, 68, 80, 55][i % 4];++ html += `<div class="ovsk-bar" style="height:${i === 0 ? 14 : 10}px;width:${i === 0 ? 45 : w}%"></div>`;++ }++ d.innerHTML = html;++ c.appendChild(d);++ if (opts.reveal !== false) c.style.display = '';++ }++++ /** Remove placeholders. Fades so a fast response does not flash a grey block. */++ function clear(target) {++ const c = el(target);++ if (!c) return;++ c.querySelectorAll('.ovsk').forEach(n => {++ n.classList.add('ovsk-fade');++ setTimeout(() => { try { n.remove(); } catch { /* */ } }, 240);++ });++ }++++ /** Placeholders that are still up after a failed load should just go away. */++ function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }++++ window.OVSkeleton = { cards, block, clear, clearAll, ensure };++})();@@@ -301,6 +301,10 @@ async function refreshRegistry() {'X-Internal-Key': config.internalApiKey,'Accept': 'application/json',},++ // Without a deadline this call can hang, and it runs during startup — an unreachable++ // or slow openvibe.network used to stall the whole boot, which during a deploy means++ // the site stays down until the fetch gives up. Config falls back to env defaults.++ signal: AbortSignal.timeout ? AbortSignal.timeout(3000) : undefined,});if (!res.ok) {console.warn('[Config] Failed to refresh URL registry from openvibe.network:', res.status);@@@ -2180,6 +2180,38 @@ function initDb() {database.exec('CREATE INDEX IF NOT EXISTS idx_clips_user_created ON clips(user_id, is_public, created_at)');} catch (e) { console.warn('[DB] performance index migration:', e.message); }++ // Second pass of hot-path indexes, found by tracing what the home page and the always-on++ // middleware actually execute. Each one replaces a recurring full table scan. They are created++ // individually so a table that does not exist in some deployment cannot stop the rest.++ for (const ix of [++ // The IP-ban check runs in middleware on EVERY request, including static assets — this was++ // a full scan of `bans` per request, the most-executed avoidable query in the system.++ 'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',++ // Looked up once per live stream on /api/streams, which every open home tab polls.++ 'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',++ // The chat auto-delete sweep runs every 30s and scanned + sorted the largest table.++ 'CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at)',++ // Home digest, per signed-in load.++ 'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)',++ 'CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',++ 'CREATE INDEX IF NOT EXISTS idx_streams_started ON streams(started_at)',++ 'CREATE INDEX IF NOT EXISTS idx_streams_live_ended ON streams(is_live, ended_at)',++ // Hero stat board aggregates.++ 'CREATE INDEX IF NOT EXISTS idx_tx_type_created ON transactions(type, created_at)',++ 'CREATE INDEX IF NOT EXISTS idx_coin_tx_created ON coin_transactions(created_at)',++ 'CREATE INDEX IF NOT EXISTS idx_users_created ON users(created_at)',++ 'CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)',++ 'CREATE INDEX IF NOT EXISTS idx_anon_ip_created ON anon_ip_mappings(created_at)',++ 'CREATE INDEX IF NOT EXISTS idx_goals_active ON donation_goals(is_active)',++ 'CREATE INDEX IF NOT EXISTS idx_arena_moments_said ON arena_mic_moments(said_at)',++ // Boot-time transcript backfill sweeps.++ 'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',++ 'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',++ 'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',++ ]) {++ try { database.exec(ix); } catch { /* table not present in this deployment */ }++ }++// Migrate: add force_nsfw column to channels (admin-set, overrides user toggle)try {const cols = database.pragma('table_info(channels)').map(c => c.name);@@@ -2653,17 +2685,45 @@ function initDb() {}// ── Generic helpers ──────────────────────────────────────────++//++// Every query in this file (and in every route module) funnels through run/get/all, and each one++// used to call prepare() again — so SQLite re-parsed and re-planned the same statement on every++// single call. A cold home page load alone compiled 200+ statements it had already compiled.++//++// better-sqlite3 statements are reusable, so we keep them in a Map keyed by SQL text. The only++// shapes worth excluding are the ones built with a variable-length `IN (?,?,?)` list, which++// produce a different SQL string every time and would fill the map with single-use entries; the++// size cap below handles those without needing to detect them.++const _stmtCache = new Map();++const _STMT_CACHE_MAX = 600;++++function stmt(sql) {++ let st = _stmtCache.get(sql);++ if (st) return st;++ st = getDb().prepare(sql);++ // Plain FIFO eviction. Hot statements are re-added on their next call, and the cap only++ // matters for the dynamic IN-list shapes, which are cheap to lose.++ if (_stmtCache.size >= _STMT_CACHE_MAX) {++ const oldest = _stmtCache.keys().next().value;++ if (oldest !== undefined) _stmtCache.delete(oldest);++ }++ _stmtCache.set(sql, st);++ return st;++}++++/** Statements belong to a connection, so a reopened database must start with an empty cache. */++function clearStatementCache() { _stmtCache.clear(); }function run(sql, params = []) {-- return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));++ return stmt(sql).run(...(Array.isArray(params) ? params : [params]));}function get(sql, params = []) {-- return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));++ return stmt(sql).get(...(Array.isArray(params) ? params : [params]));}function all(sql, params = []) {-- return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));++ return stmt(sql).all(...(Array.isArray(params) ? params : [params]));}// ── User helpers ─────────────────────────────────────────────@@@ -5976,6 +6036,8 @@ function forgiveBan(userId) {function close() {if (db) {++ // Cached statements belong to this connection — they must not outlive it.++ clearStatementCache();db.close();db = null;}@@@ -7370,7 +7432,11 @@ function deleteExpiredChatMessages(limit = 500) {FROM chat_messagesWHERE is_deleted = 0AND auto_delete_at IS NOT NULL-- AND datetime(auto_delete_at) <= CURRENT_TIMESTAMP++ -- Compared raw, not through datetime(): wrapping the column in a function makes the++ -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the++ -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape++ -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.++ AND auto_delete_at <= CURRENT_TIMESTAMPORDER BY auto_delete_at ASCLIMIT ?`,[Math.max(1, Number(limit) || 500)]@@@ -190,6 +190,18 @@ async function heroStats() {stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };}++ // Site-wide OpenCoins sit next to channel points on the board: one is the network-wide++ // currency people earn everywhere, the other is per-channel. The ledger lives on++ // OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.++ try {++ const c = await require('../monetization/wallet-client').networkCoinStats();++ if (c) {++ stats.coinsEarned = c.earned;++ stats.coinsSpent = c.spent;++ stats.coinsCirculating = c.circulating;++ stats.coinHolders = c.holders;++ }++ } catch { /* the board is better off missing four chips than failing */ }return stats;}@@@ -281,18 +293,35 @@ router.get('/pulse', async (req, res) => {});// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).--let _updateCache = { at: 0, data: null };++// The newest shipped commit, shown as a one-liner in the hero.++//++// This used to shell out with execSync on the request path. That blocks the entire event loop —++// every other request on the box waits — for up to the 4s timeout, and because the cache was only++// written after the call returned, a burst of first-loads after a deploy all queued behind their++// own separate git process. Now the refresh happens off the request path and readers only ever++// get whatever was last resolved.++let _updateCache = { at: 0, data: null, running: false };++function _refreshLatestUpdate() {++ if (_updateCache.running) return;++ _updateCache.running = true;++ require('child_process').execFile(++ 'git', ['--no-pager', 'log', '--pretty=format:%h||%s||%aI', '-1'],++ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 },++ (err, stdout) => {++ _updateCache.running = false;++ _updateCache.at = Date.now();++ if (err) return;++ const [short, subject, date] = String(stdout).trim().split('||');++ _updateCache.data = subject ? { short, subject, date } : null;++ }++ );++}function _latestUpdate() {-- if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;-- try {-- const { execSync } = require('child_process');-- const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,-- { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });-- const [short, subject, date] = String(raw).trim().split('||');-- _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };-- } catch { _updateCache = { at: Date.now(), data: null }; }++ if (Date.now() - _updateCache.at >= 5 * 60_000) _refreshLatestUpdate(); // never awaitedreturn _updateCache.data;}++// Resolve it once at startup so the very first home load already has it.++setTimeout(_refreshLatestUpdate, 1500).unref?.();// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).@@@ -466,7 +466,8 @@ if (sharedServePath) {const filePath = path.join(sharedServePath, fileName);res.setHeader('Access-Control-Allow-Origin', '*');res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');-- res.setHeader('Cache-Control', 'public, max-age=300');++ // Same deal as /js and /css: a ?v= request is a version-pinned URL and can be cached hard.++ res.setHeader('Cache-Control', req.query.v ? 'public, max-age=31536000, immutable' : 'public, max-age=300');res.setHeader('Content-Type', 'application/javascript; charset=utf-8');res.sendFile(filePath, (err) => {if (err && !res.headersSent) {@@@ -489,11 +490,27 @@ app.use('/assets/sounds', express.static(soundsPath, {},}));--// JS/CSS/HTML: no-cache + tell Cloudflare CDN not to cache at edge--// Browsers revalidate with etag (304 Not Modified), CDN always fetches fresh from origin++// JS/CSS/HTML caching.++//++// Every script and stylesheet in our HTML is referenced with ?v=N, which makes each version a++// distinct URL — so a versioned request can be cached hard, by the browser and at the Cloudflare++// edge, and a deploy that bumps ?v= is picked up instantly because it is a different URL. That++// turns a repeat visit from "revalidate ~50 files" into "read them from disk cache", and it lets++// the edge serve them without touching this box at all.++//++// A request with no ?v= is something we cannot version-track, so it keeps the old behaviour:++// revalidate every time, never cache at the edge. HTML is always no-cache — it is the document++// that names the current asset versions.const noCacheHeaders = (res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); };--app.use('/js', express.static(path.join(__dirname, '../public/js'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));--app.use('/css', express.static(path.join(__dirname, '../public/css'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));++const IMMUTABLE = 'public, max-age=31536000, immutable';++const markVersioned = (req, res, next) => { res.locals.ovVersioned = !!req.query.v; next(); };++const assetHeaders = (res) => {++ if (res.locals && res.locals.ovVersioned) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }++ else noCacheHeaders(res);++};++const assetOpts = { etag: true, lastModified: true, setHeaders: assetHeaders };++app.use('/js', markVersioned, express.static(path.join(__dirname, '../public/js'), assetOpts));++app.use('/css', markVersioned, express.static(path.join(__dirname, '../public/css'), assetOpts));// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.@@@ -128,4 +128,29 @@ async function historyForToken(userToken, limit = 50, offset = 0) {}}--module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken };++/**++ * Site-wide OpenCoins totals for the home hero stat board.++ *++ * The coin ledger lives on OpenVibe.Network, so this is the only way Live can show what the++ * network economy looks like. Held for five minutes and never allowed to fail loudly: the hero++ * simply drops the coin chips if the network is unreachable, rather than losing the whole board.++ */++let _statsCache = { at: 0, data: null };++async function networkCoinStats() {++ if (_statsCache.data && Date.now() - _statsCache.at < 5 * 60 * 1000) return _statsCache.data;++ try {++ const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;++ const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {++ headers: { 'X-Internal-Key': INTERNAL_API_KEY, Accept: 'application/json' },++ signal: ctl,++ });++ if (!res.ok) return _statsCache.data;++ const data = await res.json();++ _statsCache = { at: Date.now(), data };++ return data;++ } catch {++ return _statsCache.data; // stale is fine; missing is fine++ }++}++++module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken, networkCoinStats };
@@ -156,3 +156,34 @@
@@ -156,3 +156,34 @@
Below-the-fold sections are skipped by layout and paint until they approach the viewport.
Below-the-fold sections are skipped by layout and paint until they approach the viewport.
contain-intrinsic-size keeps the scrollbar honest so the page doesn't jump as they resolve. */
contain-intrinsic-size keeps the scrollbar honest so the page doesn't jump as they resolve. */
#page-home .home-section-lazy { content-visibility: auto; contain-intrinsic-size: auto 480px; }
#page-home .home-section-lazy { content-visibility: auto; contain-intrinsic-size: auto 480px; }
+
+/* ── "Build & Mod": the one hero button that asks for something back ──
+ Everything else in this row sends you somewhere to watch. This one says the site is yours to
+ extend, so it is allowed to be the loudest: a drifting gradient wash, a sparkle that pops off
+ the wand, and a secondary "API docs" label that keeps the destination honest without the
+ button reading like documentation. All of it pauses with the rest of the row when off-screen. */
+.hb-label small { font-size: 0.68rem; font-weight: 600; opacity: 0.62; margin-left: 5px; letter-spacing: 0.01em; }
+.hb-vibe {
+ background-image: linear-gradient(115deg, color-mix(in srgb, var(--c) 16%, transparent), transparent 42%, color-mix(in srgb, #a78bfa 14%, transparent));
+ background-size: 220% 100%;
+ animation: hbVibeWash 11s ease-in-out infinite;
+}
+@keyframes hbVibeWash { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
+.hb-vibe .hb-ico { position: relative; overflow: visible; }
+/* A spark that leaves the wand every few seconds. */
+.hb-vibe .hb-ico::after {
+ content: ''; position: absolute; top: 2px; right: 1px; width: 5px; height: 5px; border-radius: 50%;
+ background: radial-gradient(circle, #fff, var(--c) 60%, transparent 70%);
+ opacity: 0; animation: hbSpark 4.4s ease-out infinite;
+}
+@keyframes hbSpark {
+ 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
+ 10% { opacity: 1; transform: translate(2px, -3px) scale(1); }
+ 38% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }
+ 100% { opacity: 0; transform: translate(9px, -12px) scale(0.5); }
+}
+.hb-vibe:hover { box-shadow: 0 6px 20px color-mix(in srgb, var(--c) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--c) 55%, transparent); }
+#page-home .hero-cta .hb-vibe, #page-home .hero-cta .hb-vibe .hb-ico::after { animation-play-state: paused; }
+#page-home .hero-cta.fx-live .hb-vibe, #page-home .hero-cta.fx-live .hb-vibe .hb-ico::after { animation-play-state: running; }
+@media (max-width: 520px) { .hb-label small { display: none; } }
+@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }
@@ -61,19 +61,54 @@
@@ -61,19 +61,54 @@
})();
})();
</script>
</script>
- <!-- Stylesheets -->
+ <!-- Placeholder styling, inline on purpose.
+ The skeletons below are in the HTML so the page reserves the space each feed will occupy
+ before a single script runs. That only works if their styling is here too — a stylesheet
+ request would land after the first paint, which is the moment it is needed. ~1KB. -->
+ <style id="ovsk-css">
+ .ovsk{position:relative;overflow:hidden;background:var(--bg-card,#171826);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:14px}
+ .ovsk-bar{position:relative;overflow:hidden;background:color-mix(in srgb,var(--text-muted,#8b93ad) 14%,transparent);border-radius:6px}
+ .ovsk::after,.ovsk-bar::after{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,color-mix(in srgb,var(--text-primary,#f1f4fb) 7%,transparent) 50%,transparent 80%);transform:translateX(-100%);animation:ovskSweep 1.5s ease-in-out infinite}
+ @keyframes ovskSweep{to{transform:translateX(100%)}}
+ .ovsk-card{display:flex;flex-direction:column}
+ .ovsk-thumb{aspect-ratio:16/9;width:100%;background:color-mix(in srgb,var(--text-muted,#8b93ad) 10%,transparent)}
+ .ovsk-meta{display:flex;gap:10px;align-items:center;padding:10px 12px 12px}
+ .ovsk-av{width:32px;height:32px;border-radius:50%;flex:none}
+ .ovsk-lines{flex:1;min-width:0;display:grid;gap:6px}
+ .ovsk-panel{padding:14px;display:grid;gap:10px}
+ .ovsk-fade{animation:ovskFade .25s ease forwards}
+ @keyframes ovskFade{to{opacity:0}}
+ @media (max-width:760px){#stream-grid-live .ovsk-card:nth-of-type(n+3){display:none}}
+ @media (prefers-reduced-motion:reduce){.ovsk::after,.ovsk-bar::after{animation:none}}
+ </style>
+
+ <!-- Stylesheets.
+ Blocking set = what the home page paints with. Everything else belongs to another route
+ (the broadcast desk, the messenger, the cosmetics modal, after-show reports, the guide
+ 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=166">
<link rel="stylesheet" href="/css/style.css?v=166">
- <link rel="stylesheet" href="/css/chat-shared.css?v=1">
- <link rel="stylesheet" href="/css/broadcast.css?v=11">
- <link rel="stylesheet" href="/css/cosmetics.css?v=2">
- <link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/home-fx.css?v=9">
+ <link rel="stylesheet" href="/css/home-fx.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
<link rel="stylesheet" href="/css/home-tour.css?v=4">
- <link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
- <link rel="stylesheet" href="/css/recap.css?v=1">
- <link rel="stylesheet" href="/css/context-cards.css?v=3">
<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">
+ <link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/context-cards.css?v=3" media="print" onload="this.media='all';this.onload=null">
+ <noscript>
+ <link rel="stylesheet" href="/css/chat-shared.css?v=1">
+ <link rel="stylesheet" href="/css/broadcast.css?v=11">
+ <link rel="stylesheet" href="/css/cosmetics.css?v=2">
+ <link rel="stylesheet" href="/css/messenger.css?v=1">
+ <link rel="stylesheet" href="/css/ov-guide.css?v=1">
+ <link rel="stylesheet" href="/css/recap.css?v=1">
+ <link rel="stylesheet" href="/css/context-cards.css?v=3">
+ </noscript>
<!-- JSON-LD Structured Data -->
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
<script type="application/ld+json">
@@ -248,7 +283,7 @@
@@ -248,7 +283,7 @@
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate" id="hero-rotate"></span></span></p>
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate" id="hero-rotate"></span></span></p>
<p class="hero-quip" id="hero-quip" aria-live="polite"></p>
<p class="hero-quip" id="hero-quip" aria-live="polite"></p>
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
- <div class="hero-stats" id="hero-stats" aria-label="Site stats"></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-egg" id="hero-egg" style="display:none">
<div class="hero-egg" id="hero-egg" style="display:none">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<span class="hero-egg-icon">🥚</span>
<span class="hero-egg-icon">🥚</span>
@@ -286,12 +321,12 @@
@@ -286,12 +321,12 @@
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"
- data-ovtip-title="Built for vibe coders"
+ data-ovtip-title="Mod it, bot it, build on it"
data-ovtip-icon="fa-wand-magic-sparkles"
data-ovtip-icon="fa-wand-magic-sparkles"
data-ovtip-accent="#f472b6"
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
data-ovtip-place="top"
- data-ovtip="Every feature here has an <b>open API</b> — chat, streams, clips, overlays, robots, the lot. <em>You don't need to know how to code.</em> Point an AI at these docs, describe the bot or overlay you want, and it writes it for you."
- data-ovtip-foot="<i class="fa-solid fa-heart"></i> Open here, good vibes — come build something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">API Docs</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ 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>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
<div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
@@ -312,10 +347,10 @@
@@ -312,10 +347,10 @@
<div class="container">
<div class="container">
<!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
<!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
- <div class="home-digest" id="home-digest" style="display:none"></div>
+ <div class="home-digest" id="home-digest"><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>
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
- <div class="home-star-section" id="home-star-section" style="display:none"></div>
+ <div class="home-star-section" id="home-star-section"><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 class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
<!-- Live Streams -->
<!-- Live Streams -->
<div class="section-header">
<div class="section-header">
@@ -323,7 +358,11 @@
@@ -323,7 +358,11 @@
<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">
- <div class="empty-state" id="no-live-streams">
+ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
+ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
+ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
+ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
+ <div class="empty-state" id="no-live-streams" style="display:none">
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<p>No one is streaming right now</p>
<p>No one is streaming right now</p>
<p class="muted">Be the first to go live!</p>
<p class="muted">Be the first to go live!</p>
@@ -5084,7 +5123,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5084,7 +5123,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=142" defer></script>
+<script src="/js/ov-skeleton.js?v=1" defer></script>
+<script src="/js/app.js?v=144" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" 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>
@@ -5094,7 +5134,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5094,7 +5134,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
<script src="/js/controls.js?v=8" defer></script>
-<script src="/js/onvif-dashboard.js?v=5" defer></script>
+<script src="/js/onvif-dashboard.js?v=6" defer></script>
<script src="/js/dashboard.js?v=16" defer></script>
<script src="/js/dashboard.js?v=16" defer></script>
<script src="/js/admin.js?v=8" defer></script>
<script src="/js/admin.js?v=8" defer></script>
<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
@@ -5149,7 +5189,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5149,7 +5189,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-workspace.js?v=16" defer></script>
<script src="/js/broadcast-workspace.js?v=16" defer></script>
-<script src="/js/broadcast.js?v=28" defer></script>
+<script src="/js/broadcast.js?v=29" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/settings.js?v=12" defer></script>
@@ -5164,8 +5204,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5164,8 +5204,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/ov-mark.js?v=1" defer></script>
<script src="/js/ov-mark.js?v=1" defer></script>
-<script src="/shared/tooltip.js" defer></script>
-<script src="/shared/footer.js" defer></script>
+<script src="/shared/tooltip.js?v=1" defer></script>
+<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=6" defer></script>
<script src="/js/home-fx.js?v=6" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
<script src="/js/home-tour.js?v=6" defer></script>
@@ -5173,9 +5213,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5173,9 +5213,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/guides-live.js?v=4" defer></script>
<script src="/js/guides-live.js?v=4" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
-<script src="/shared/theme-loader.js" defer></script>
+<script src="/shared/theme-loader.js?v=1" defer></script>
<script src="/shared/notification-ui.js?v=2" defer></script>
<script src="/shared/notification-ui.js?v=2" defer></script>
-<script src="/shared/account-switcher.js" defer></script>
<script>
<script>
// Initialize OpenVibe notifications and theme sync (navbar is handled natively by openvibelive).
// Initialize OpenVibe notifications and theme sync (navbar is handled natively by openvibelive).
// Runs on DOMContentLoaded so the now-deferred shared libs (OpenVibeThemeLoader / OpenVibeNotifications)
// Runs on DOMContentLoaded so the now-deferred shared libs (OpenVibeThemeLoader / OpenVibeNotifications)
@@ -1307,7 +1307,15 @@ function renderHeroStats(stats) {
@@ -1307,7 +1307,15 @@ function renderHeroStats(stats) {
{ icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },
{ icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },
{ icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
{ icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
{ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
{ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },
+ // OpenCoins are network-wide; channel points below are per channel. Shown only when
+ // the wallet service answered — four zeroed chips would read as "nobody has any".
+ ...(stats.coinsEarned != null ? [
+ { icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins Earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools' },
+ { icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins Spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },
+ { icon: 'fa-vault', num: stats.coinsCirculating, label: 'Coins Held', title: 'OpenCoins sitting in wallets right now, across every OpenVibe site' },
+ { icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },
+ ] : []),
+ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', recent: R.points },
{ icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },
{ icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },
{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
],
],
@@ -1866,6 +1874,17 @@ async function loadHome() {
@@ -1866,6 +1874,17 @@ async function loadHome() {
void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans
void loadHeroData(); // stats bar, floating-thumbnail collage, AI slogans
void loadHeroEgg(); // daily AI easter egg widget
void loadHeroEgg(); // daily AI easter egg widget
+ // Claim the space every feed is about to fill. Without this the page arrives as a short
+ // column that grows in jumps as each request lands, which reads as slow and shoves content
+ // around under the reader's thumb. Placeholders are cleared by whoever renders real content,
+ // with a backstop below so nothing shimmers forever after a failed request.
+ if (window.OVSkeleton) {
+ OVSkeleton.cards('stream-grid-live');
+ OVSkeleton.block('home-digest', { rows: 3 });
+ OVSkeleton.block('home-star-section', { rows: 4, height: 140 });
+ setTimeout(() => OVSkeleton.clearAll(), 9000);
+ }
+
// Reset homepage pagination on fresh load
// Reset homepage pagination on fresh load
_homeRecentOnlinePage = 1;
_homeRecentOnlinePage = 1;
_homeRecentVodsPage = 1;
_homeRecentVodsPage = 1;
@@ -1879,7 +1898,13 @@ async function loadHome() {
@@ -1879,7 +1898,13 @@ async function loadHome() {
const noLiveEl = document.getElementById('no-live-streams');
const noLiveEl = document.getElementById('no-live-streams');
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
renderStreamGrid('stream-grid-live', streams, true);
- } catch (e) { console.error('Failed to load live streams', e); }
+ } catch (e) {
+ console.error('Failed to load live streams', e);
+ // Never leave placeholders shimmering over a failed request.
+ if (window.OVSkeleton) OVSkeleton.clear('stream-grid-live');
+ const noLiveEl = document.getElementById('no-live-streams');
+ if (noLiveEl) noLiveEl.style.display = '';
+ }
loadHomeRecentOnline();
loadHomeRecentOnline();
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
@@ -1977,7 +2002,8 @@ async function loadHomeDigest() {
@@ -1977,7 +2002,8 @@ async function loadHomeDigest() {
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { since = localStorage.getItem(KEY); } catch { /* */ }
try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
let d;
let d;
- try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); } catch { return; }
+ try { d = await api(`/home/digest${since ? `?since=${encodeURIComponent(since)}` : ''}`); }
+ catch { box.innerHTML = ''; box.style.display = 'none'; return; }
if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }
if (!d || (!d.liveNow?.length && !d.streamed?.length && !d.hot?.length)) { box.style.display = 'none'; return; }
const away = !!(since && currentUser);
const away = !!(since && currentUser);
const n = (v) => Number(v || 0).toLocaleString();
const n = (v) => Number(v || 0).toLocaleString();
@@ -2305,11 +2331,20 @@ function streamCardHTML(s, isLive) {
@@ -2305,11 +2331,20 @@ function streamCardHTML(s, isLive) {
function renderStreamGrid(containerId, streams, isLive) {
function renderStreamGrid(containerId, streams, isLive) {
const c = document.getElementById(containerId);
const c = document.getElementById(containerId);
+ if (!c) return;
+ // Placeholders have done their job the moment real data lands. Remove them by class rather
+ // than clearing the container, because the live grid keeps its empty-state child in there.
+ c.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } });
if (!streams.length) {
if (!streams.length) {
if (!isLive) c.innerHTML = '<div class="empty-state"><p class="muted">No recent streams</p></div>';
if (!isLive) c.innerHTML = '<div class="empty-state"><p class="muted">No recent streams</p></div>';
return;
return;
}
}
+ // Keep the live grid's empty-state node across a re-render. Replacing innerHTML used to
+ // delete it outright, so once anyone went live the "nobody is streaming" message could never
+ // come back when they all stopped — the grid just sat there blank.
+ const empty = c.querySelector('.empty-state');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
+ if (empty) { empty.style.display = 'none'; c.appendChild(empty); }
}
}
/* ── Real-time home updates ─────────────────────────────────── */
/* ── Real-time home updates ─────────────────────────────────── */
@@ -5753,7 +5753,19 @@ function _initPipDragResize() {
@@ -5753,7 +5753,19 @@ function _initPipDragResize() {
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', () => {
initBroadcastSettingsListeners();
initBroadcastSettingsListeners();
- initBroadcastTabs();
+ // Restoring the saved tab can issue broadcast API calls and force a layout on a hidden page.
+ // Only do it once the broadcast desk is the route the visitor is actually on.
+ const _bcPage = document.getElementById('page-broadcast');
+ const _bcTabsOnce = () => {
+ if (!_bcPage || !_bcPage.classList.contains('active')) return;
+ if (_bcPage.dataset.tabsInit) return;
+ _bcPage.dataset.tabsInit = '1';
+ initBroadcastTabs();
+ };
+ _bcTabsOnce();
+ if (_bcPage && 'MutationObserver' in window) {
+ new MutationObserver(_bcTabsOnce).observe(_bcPage, { attributes: true, attributeFilter: ['class'] });
+ }
// Update mobile chat FAB visibility on resize
// Update mobile chat FAB visibility on resize
window.addEventListener('resize', () => {
window.addEventListener('resize', () => {
@@ -352,9 +352,20 @@ async function showEditCameraModal(cameraId) {
@@ -352,9 +352,20 @@ async function showEditCameraModal(cameraId) {
}
}
}
}
-// Initialize on page load
+// Every page's markup lives in one document, so #dash-cameras-list exists even on the home page —
+// this used to fire an ONVIF camera query for anyone landing on the front page. Load the cameras
+// when the dashboard is actually on screen, and again whenever it becomes so.
+function _onvifMaybeLoad() {
+ const page = document.getElementById('page-dashboard');
+ if (!page || !page.classList.contains('active')) return;
+ if (page.dataset.onvifLoaded) return;
+ page.dataset.onvifLoaded = '1';
+ if (document.getElementById('dash-cameras-list')) loadDashboardCameras();
+}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', () => {
- if (document.getElementById('dash-cameras-list')) {
- loadDashboardCameras();
+ _onvifMaybeLoad();
+ const page = document.getElementById('page-dashboard');
+ if (page && 'MutationObserver' in window) {
+ new MutationObserver(_onvifMaybeLoad).observe(page, { attributes: true, attributeFilter: ['class'] });
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+/**
+ * ov-skeleton.js — placeholder shapes while a section is loading.
+ *
+ * Every feed on this site used to be `display:none` until its fetch came back, so the page
+ * arrived as a short column that grew in jumps as each request landed. That reads as slow even
+ * when it isn't, and every late section shoves the page around under the reader's thumb.
+ *
+ * A skeleton fixes both: the space a section is about to occupy is claimed immediately with
+ * shimmering blocks in roughly the shape of the real thing, so the layout stops moving and the
+ * page looks like it is filling in rather than stalling.
+ *
+ * Deliberately small and dependency-free:
+ * OVSkeleton.cards('stream-grid-live') — a responsive row of stream-card placeholders
+ * OVSkeleton.block(el, { rows, height }) — a generic panel placeholder
+ * OVSkeleton.clear(el) — remove placeholders from a container
+ *
+ * Counts follow the container's real width, so a phone gets two placeholders and a wide desktop
+ * gets six. There is no point rendering eight shimmering cards into a 400px column.
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OVSkeleton) return;
+
+ const CSS = `
+.ovsk { position: relative; overflow: hidden; background: var(--bg-card, #171826);
+ border: 1px solid var(--border, rgba(255,255,255,.08)); border-radius: 14px; }
+.ovsk-bar { background: color-mix(in srgb, var(--text-muted, #8b93ad) 14%, transparent); border-radius: 6px; }
+/* One sweep definition, shared by every placeholder, so a screen full of them moves as one. */
+.ovsk::after, .ovsk-bar::after { content: ''; position: absolute; inset: 0;
+ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--text-primary, #f1f4fb) 7%, transparent) 50%, transparent 80%);
+ transform: translateX(-100%); animation: ovskSweep 1.5s ease-in-out infinite; }
+.ovsk-bar { position: relative; overflow: hidden; }
+@keyframes ovskSweep { to { transform: translateX(100%); } }
+.ovsk-card { display: flex; flex-direction: column; }
+.ovsk-thumb { aspect-ratio: 16 / 9; width: 100%; background: color-mix(in srgb, var(--text-muted, #8b93ad) 10%, transparent); }
+.ovsk-meta { display: flex; gap: 10px; align-items: center; padding: 10px 12px 12px; }
+.ovsk-av { width: 32px; height: 32px; border-radius: 50%; flex: none; }
+.ovsk-lines { flex: 1; min-width: 0; display: grid; gap: 6px; }
+.ovsk-panel { padding: 14px; display: grid; gap: 10px; }
+.ovsk-fade { animation: ovskFade .25s ease forwards; }
+@keyframes ovskFade { to { opacity: 0; } }
+@media (prefers-reduced-motion: reduce) { .ovsk::after, .ovsk-bar::after { animation: none; } }`;
+
+ function ensure() {
+ if (document.getElementById('ovsk-css')) return;
+ const st = document.createElement('style'); st.id = 'ovsk-css'; st.textContent = CSS;
+ document.head.appendChild(st);
+ }
+
+ const el = (target) => typeof target === 'string' ? document.getElementById(target) : target;
+
+ /** How many placeholders actually fit — never more than the container can show. */
+ function fitCount(container, minWidth, max) {
+ const w = container.getBoundingClientRect().width || window.innerWidth;
+ const perRow = Math.max(1, Math.floor(w / minWidth));
+ // Two rows on a roomy screen, one on a phone, capped so we never paint a wall of grey.
+ return Math.min(max, perRow * (w >= 900 ? 2 : 1));
+ }
+
+ function cards(target, opts = {}) {
+ const c = el(target);
+ if (!c || c.querySelector('.ovsk-card')) return;
+ ensure();
+ const n = opts.count || fitCount(c, opts.minWidth || 300, opts.max || 6);
+ const frag = document.createDocumentFragment();
+ for (let i = 0; i < n; i++) {
+ const d = document.createElement('div');
+ d.className = 'ovsk ovsk-card';
+ d.setAttribute('aria-hidden', 'true');
+ d.innerHTML = '<div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div>'
+ + '<div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div>'
+ + '<div class="ovsk-bar" style="height:9px;width:45%"></div></div></div>';
+ frag.appendChild(d);
+ }
+ c.appendChild(frag);
+ }
+
+ /** A generic panel placeholder for sections that are not card grids. */
+ function block(target, opts = {}) {
+ const c = el(target);
+ if (!c || c.querySelector('.ovsk-panel')) return;
+ ensure();
+ const rows = opts.rows || 3;
+ const d = document.createElement('div');
+ d.className = 'ovsk ovsk-panel';
+ d.setAttribute('aria-hidden', 'true');
+ if (opts.height) d.style.minHeight = typeof opts.height === 'number' ? `${opts.height}px` : opts.height;
+ let html = '';
+ for (let i = 0; i < rows; i++) {
+ const w = [92, 68, 80, 55][i % 4];
+ html += `<div class="ovsk-bar" style="height:${i === 0 ? 14 : 10}px;width:${i === 0 ? 45 : w}%"></div>`;
+ }
+ d.innerHTML = html;
+ c.appendChild(d);
+ if (opts.reveal !== false) c.style.display = '';
+ }
+
+ /** Remove placeholders. Fades so a fast response does not flash a grey block. */
+ function clear(target) {
+ const c = el(target);
+ if (!c) return;
+ c.querySelectorAll('.ovsk').forEach(n => {
+ n.classList.add('ovsk-fade');
+ setTimeout(() => { try { n.remove(); } catch { /* */ } }, 240);
+ });
+ }
+
+ /** Placeholders that are still up after a failed load should just go away. */
+ function clearAll() { document.querySelectorAll('.ovsk').forEach(n => { try { n.remove(); } catch { /* */ } }); }
+
+ window.OVSkeleton = { cards, block, clear, clearAll, ensure };
+})();
@@ -301,6 +301,10 @@ async function refreshRegistry() {
@@ -301,6 +301,10 @@ async function refreshRegistry() {
'X-Internal-Key': config.internalApiKey,
'X-Internal-Key': config.internalApiKey,
'Accept': 'application/json',
'Accept': 'application/json',
},
},
+ // Without a deadline this call can hang, and it runs during startup — an unreachable
+ // or slow openvibe.network used to stall the whole boot, which during a deploy means
+ // the site stays down until the fetch gives up. Config falls back to env defaults.
+ signal: AbortSignal.timeout ? AbortSignal.timeout(3000) : undefined,
});
});
if (!res.ok) {
if (!res.ok) {
console.warn('[Config] Failed to refresh URL registry from openvibe.network:', res.status);
console.warn('[Config] Failed to refresh URL registry from openvibe.network:', res.status);
@@ -2180,6 +2180,38 @@ function initDb() {
@@ -2180,6 +2180,38 @@ function initDb() {
database.exec('CREATE INDEX IF NOT EXISTS idx_clips_user_created ON clips(user_id, is_public, created_at)');
database.exec('CREATE INDEX IF NOT EXISTS idx_clips_user_created ON clips(user_id, is_public, created_at)');
} catch (e) { console.warn('[DB] performance index migration:', e.message); }
} catch (e) { console.warn('[DB] performance index migration:', e.message); }
+ // Second pass of hot-path indexes, found by tracing what the home page and the always-on
+ // middleware actually execute. Each one replaces a recurring full table scan. They are created
+ // individually so a table that does not exist in some deployment cannot stop the rest.
+ for (const ix of [
+ // The IP-ban check runs in middleware on EVERY request, including static assets — this was
+ // a full scan of `bans` per request, the most-executed avoidable query in the system.
+ 'CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip_address)',
+ // Looked up once per live stream on /api/streams, which every open home tab polls.
+ 'CREATE INDEX IF NOT EXISTS idx_restream_dest_user ON restream_destinations(user_id)',
+ // The chat auto-delete sweep runs every 30s and scanned + sorted the largest table.
+ 'CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at)',
+ // Home digest, per signed-in load.
+ 'CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id)',
+ 'CREATE INDEX IF NOT EXISTS idx_stream_recaps_user ON stream_recaps(user_id, created_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_streams_started ON streams(started_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_streams_live_ended ON streams(is_live, ended_at)',
+ // Hero stat board aggregates.
+ 'CREATE INDEX IF NOT EXISTS idx_tx_type_created ON transactions(type, created_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_coin_tx_created ON coin_transactions(created_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_users_created ON users(created_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)',
+ 'CREATE INDEX IF NOT EXISTS idx_anon_ip_created ON anon_ip_mappings(created_at)',
+ 'CREATE INDEX IF NOT EXISTS idx_goals_active ON donation_goals(is_active)',
+ 'CREATE INDEX IF NOT EXISTS idx_arena_moments_said ON arena_mic_moments(said_at)',
+ // Boot-time transcript backfill sweeps.
+ 'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',
+ 'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',
+ 'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
+ ]) {
+ try { database.exec(ix); } catch { /* table not present in this deployment */ }
+ }
+
// Migrate: add force_nsfw column to channels (admin-set, overrides user toggle)
// Migrate: add force_nsfw column to channels (admin-set, overrides user toggle)
try {
try {
const cols = database.pragma('table_info(channels)').map(c => c.name);
const cols = database.pragma('table_info(channels)').map(c => c.name);
@@ -2653,17 +2685,45 @@ function initDb() {
@@ -2653,17 +2685,45 @@ function initDb() {
}
}
// ── Generic helpers ──────────────────────────────────────────
// ── Generic helpers ──────────────────────────────────────────
+//
+// Every query in this file (and in every route module) funnels through run/get/all, and each one
+// used to call prepare() again — so SQLite re-parsed and re-planned the same statement on every
+// single call. A cold home page load alone compiled 200+ statements it had already compiled.
+//
+// better-sqlite3 statements are reusable, so we keep them in a Map keyed by SQL text. The only
+// shapes worth excluding are the ones built with a variable-length `IN (?,?,?)` list, which
+// produce a different SQL string every time and would fill the map with single-use entries; the
+// size cap below handles those without needing to detect them.
+const _stmtCache = new Map();
+const _STMT_CACHE_MAX = 600;
+
+function stmt(sql) {
+ let st = _stmtCache.get(sql);
+ if (st) return st;
+ st = getDb().prepare(sql);
+ // Plain FIFO eviction. Hot statements are re-added on their next call, and the cap only
+ // matters for the dynamic IN-list shapes, which are cheap to lose.
+ if (_stmtCache.size >= _STMT_CACHE_MAX) {
+ const oldest = _stmtCache.keys().next().value;
+ if (oldest !== undefined) _stmtCache.delete(oldest);
+ }
+ _stmtCache.set(sql, st);
+ return st;
+}
+
+/** Statements belong to a connection, so a reopened database must start with an empty cache. */
+function clearStatementCache() { _stmtCache.clear(); }
function run(sql, params = []) {
function run(sql, params = []) {
- return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));
+ return stmt(sql).run(...(Array.isArray(params) ? params : [params]));
}
}
function get(sql, params = []) {
function get(sql, params = []) {
- return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));
+ return stmt(sql).get(...(Array.isArray(params) ? params : [params]));
}
}
function all(sql, params = []) {
function all(sql, params = []) {
- return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));
+ return stmt(sql).all(...(Array.isArray(params) ? params : [params]));
}
}
// ── User helpers ─────────────────────────────────────────────
// ── User helpers ─────────────────────────────────────────────
@@ -5976,6 +6036,8 @@ function forgiveBan(userId) {
@@ -5976,6 +6036,8 @@ function forgiveBan(userId) {
function close() {
function close() {
if (db) {
if (db) {
+ // Cached statements belong to this connection — they must not outlive it.
+ clearStatementCache();
db.close();
db.close();
db = null;
db = null;
}
}
@@ -7370,7 +7432,11 @@ function deleteExpiredChatMessages(limit = 500) {
@@ -7370,7 +7432,11 @@ function deleteExpiredChatMessages(limit = 500) {
FROM chat_messages
FROM chat_messages
WHERE is_deleted = 0
WHERE is_deleted = 0
AND auto_delete_at IS NOT NULL
AND auto_delete_at IS NOT NULL
- AND datetime(auto_delete_at) <= CURRENT_TIMESTAMP
+ -- Compared raw, not through datetime(): wrapping the column in a function makes the
+ -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
+ -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
+ -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.
+ AND auto_delete_at <= CURRENT_TIMESTAMP
ORDER BY auto_delete_at ASC
ORDER BY auto_delete_at ASC
LIMIT ?`,
LIMIT ?`,
[Math.max(1, Number(limit) || 500)]
[Math.max(1, Number(limit) || 500)]
@@ -190,6 +190,18 @@ async function heroStats() {
@@ -190,6 +190,18 @@ async function heroStats() {
stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);
stats.streamHours = Math.round((m.durationSeconds || 0) / 3600);
stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };
stats.recent = { ...stats.recent, vods: m.recent?.vods, clips: m.recent?.clips, hours: m.recent?.hours };
}
}
+ // Site-wide OpenCoins sit next to channel points on the board: one is the network-wide
+ // currency people earn everywhere, the other is per-channel. The ledger lives on
+ // OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
+ try {
+ const c = await require('../monetization/wallet-client').networkCoinStats();
+ if (c) {
+ stats.coinsEarned = c.earned;
+ stats.coinsSpent = c.spent;
+ stats.coinsCirculating = c.circulating;
+ stats.coinHolders = c.holders;
+ }
+ } catch { /* the board is better off missing four chips than failing */ }
return stats;
return stats;
}
}
@@ -281,18 +293,35 @@ router.get('/pulse', async (req, res) => {
@@ -281,18 +293,35 @@ router.get('/pulse', async (req, res) => {
});
});
// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).
// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).
-let _updateCache = { at: 0, data: null };
+// The newest shipped commit, shown as a one-liner in the hero.
+//
+// This used to shell out with execSync on the request path. That blocks the entire event loop —
+// every other request on the box waits — for up to the 4s timeout, and because the cache was only
+// written after the call returned, a burst of first-loads after a deploy all queued behind their
+// own separate git process. Now the refresh happens off the request path and readers only ever
+// get whatever was last resolved.
+let _updateCache = { at: 0, data: null, running: false };
+function _refreshLatestUpdate() {
+ if (_updateCache.running) return;
+ _updateCache.running = true;
+ require('child_process').execFile(
+ 'git', ['--no-pager', 'log', '--pretty=format:%h||%s||%aI', '-1'],
+ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 },
+ (err, stdout) => {
+ _updateCache.running = false;
+ _updateCache.at = Date.now();
+ if (err) return;
+ const [short, subject, date] = String(stdout).trim().split('||');
+ _updateCache.data = subject ? { short, subject, date } : null;
+ }
+ );
+}
function _latestUpdate() {
function _latestUpdate() {
- if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;
- try {
- const { execSync } = require('child_process');
- const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,
- { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });
- const [short, subject, date] = String(raw).trim().split('||');
- _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };
- } catch { _updateCache = { at: Date.now(), data: null }; }
+ if (Date.now() - _updateCache.at >= 5 * 60_000) _refreshLatestUpdate(); // never awaited
return _updateCache.data;
return _updateCache.data;
}
}
+// Resolve it once at startup so the very first home load already has it.
+setTimeout(_refreshLatestUpdate, 1500).unref?.();
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
@@ -466,7 +466,8 @@ if (sharedServePath) {
@@ -466,7 +466,8 @@ if (sharedServePath) {
const filePath = path.join(sharedServePath, fileName);
const filePath = path.join(sharedServePath, fileName);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
- res.setHeader('Cache-Control', 'public, max-age=300');
+ // Same deal as /js and /css: a ?v= request is a version-pinned URL and can be cached hard.
+ res.setHeader('Cache-Control', req.query.v ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.sendFile(filePath, (err) => {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) {
if (err && !res.headersSent) {
@@ -489,11 +490,27 @@ app.use('/assets/sounds', express.static(soundsPath, {
@@ -489,11 +490,27 @@ app.use('/assets/sounds', express.static(soundsPath, {
},
},
}));
}));
-// JS/CSS/HTML: no-cache + tell Cloudflare CDN not to cache at edge
-// Browsers revalidate with etag (304 Not Modified), CDN always fetches fresh from origin
+// JS/CSS/HTML caching.
+//
+// Every script and stylesheet in our HTML is referenced with ?v=N, which makes each version a
+// distinct URL — so a versioned request can be cached hard, by the browser and at the Cloudflare
+// edge, and a deploy that bumps ?v= is picked up instantly because it is a different URL. That
+// turns a repeat visit from "revalidate ~50 files" into "read them from disk cache", and it lets
+// the edge serve them without touching this box at all.
+//
+// A request with no ?v= is something we cannot version-track, so it keeps the old behaviour:
+// revalidate every time, never cache at the edge. HTML is always no-cache — it is the document
+// that names the current asset versions.
const noCacheHeaders = (res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); };
const noCacheHeaders = (res) => { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('CDN-Cache-Control', 'no-store'); };
-app.use('/js', express.static(path.join(__dirname, '../public/js'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));
-app.use('/css', express.static(path.join(__dirname, '../public/css'), { maxAge: 0, etag: true, lastModified: true, setHeaders: noCacheHeaders }));
+const IMMUTABLE = 'public, max-age=31536000, immutable';
+const markVersioned = (req, res, next) => { res.locals.ovVersioned = !!req.query.v; next(); };
+const assetHeaders = (res) => {
+ if (res.locals && res.locals.ovVersioned) { res.setHeader('Cache-Control', IMMUTABLE); res.setHeader('CDN-Cache-Control', IMMUTABLE); }
+ else noCacheHeaders(res);
+};
+const assetOpts = { etag: true, lastModified: true, setHeaders: assetHeaders };
+app.use('/js', markVersioned, express.static(path.join(__dirname, '../public/js'), assetOpts));
+app.use('/css', markVersioned, express.static(path.join(__dirname, '../public/css'), assetOpts));
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
@@ -128,4 +128,29 @@ async function historyForToken(userToken, limit = 50, offset = 0) {
@@ -128,4 +128,29 @@ async function historyForToken(userToken, limit = 50, offset = 0) {
}
}
}
}
-module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken };
+/**
+ * Site-wide OpenCoins totals for the home hero stat board.
+ *
+ * The coin ledger lives on OpenVibe.Network, so this is the only way Live can show what the
+ * network economy looks like. Held for five minutes and never allowed to fail loudly: the hero
+ * simply drops the coin chips if the network is unreachable, rather than losing the whole board.
+ */
+let _statsCache = { at: 0, data: null };
+async function networkCoinStats() {
+ if (_statsCache.data && Date.now() - _statsCache.at < 5 * 60 * 1000) return _statsCache.data;
+ try {
+ const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;
+ const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {
+ headers: { 'X-Internal-Key': INTERNAL_API_KEY, Accept: 'application/json' },
+ signal: ctl,
+ });
+ if (!res.ok) return _statsCache.data;
+ const data = await res.json();
+ _statsCache = { at: Date.now(), data };
+ return data;
+ } catch {
+ return _statsCache.data; // stale is fine; missing is fine
+ }
+}
+
+module.exports = { WalletError, networkUserId, credit, debit, transfer, balanceForToken, historyForToken, networkCoinStats };