openvibers/openvibe.live Commits
Footer rebuilt as a responsive link hub; Arena and home get fuller structured data
Changes
@@@ -32,12 +32,15 @@box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;}--/* Slow sheen that crosses each button in turn — the idle "these are buttons" tell. */++/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and a++ small per-button offset makes a single highlight travel across the row. */++.hb { --hb-wave: 5s; --hb-step: 0.15s; }.hb::before {-- content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;-- background: linear-gradient(105deg, transparent 38%, color-mix(in srgb, var(--c) 26%, transparent) 50%, transparent 62%);-- background-size: 280% 100%; background-position: 220% 0;-- animation: hbSheen 9s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.7s);++ content: ''; position: absolute; top: 0; bottom: 0; left: -65%; width: 55%; z-index: -1;++ pointer-events: none; will-change: transform;++ background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);++ animation: hbWave var(--hb-wave) linear infinite;++ animation-delay: calc(var(--i, 0) * var(--hb-step));}.hb::after {content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;@@@ -52,7 +55,7 @@border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;-- animation: hbBreathe 4.5s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.35s);++ animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));}.hb-label { flex: 1; text-align: left; }/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */@@@ -69,8 +72,8 @@.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }@keyframes hbOrbit { to { --a: 360deg; } }--@keyframes hbSheen { 0%, 62% { background-position: 220% 0; } 88%, 100% { background-position: -120% 0; } }--@keyframes hbBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }++@keyframes hbWave { 0% { transform: translateX(0); } 22%, 100% { transform: translateX(330%); } }++@keyframes hbPulse { 0%, 100% { transform: scale(1); } 7% { transform: scale(1.11); } 20% { transform: scale(1); } }.hb-stream { --c: var(--accent); color: #fff;background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);border-color: color-mix(in srgb, var(--accent) 65%, transparent);@@@ -116,7 +119,7 @@.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }--.rs-lite .hb::before { animation-duration: 14s; }++.rs-lite .hb { --hb-wave: 8s; }.rs-lite #page-home .stream-card { transition: none; }/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────@@@ -6136,19 +6136,63 @@ textarea.form-input { resize: vertical; min-height: 60px; }.footer {background: var(--bg-secondary);border-top: 1px solid var(--border);-- padding: 8px 16px;++ /* The floating chat/DM buttons sit above the fold on phones — the old 8px footer put the++ legal links underneath them, which is why Terms/Privacy/DMCA were cut off. */++ padding: clamp(24px, 4vw, 40px) clamp(14px, 4vw, 32px) calc(clamp(20px, 3vw, 28px) + env(safe-area-inset-bottom, 0px));++ margin-top: 40px;++}++.footer-inner { max-width: 1240px; margin: 0 auto; }++.footer-cols {++ display: grid; gap: clamp(20px, 3vw, 36px);++ grid-template-columns: minmax(230px, 1.6fr) repeat(4, minmax(120px, 1fr));++ align-items: start;++}++.footer-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }++.footer-col h3 {++ margin: 0 0 2px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;++ text-transform: uppercase; color: var(--text-muted);++}++.footer-col a {++ color: var(--text-secondary); font-size: 0.86rem; text-decoration: none; line-height: 1.4;++ transition: color 0.15s, transform 0.15s; width: fit-content;++}++.footer-col a:hover { color: var(--accent); transform: translateX(2px); }++.footer-brand {++ display: inline-flex; align-items: center; gap: 9px; color: var(--text-primary);++ font-size: 1.05rem; font-weight: 700; text-decoration: none; margin-bottom: 2px;++}++.footer-brand strong { color: var(--accent); font-weight: 800; }++.footer-brand:hover { color: var(--text-primary); }++.footer-tagline { margin: 0; font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); max-width: 46ch; }++.footer-social { display: flex; gap: 8px; margin-top: 4px; }++.footer-social a {++ width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center;++ color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border);++ font-size: 0.95rem; transition: color 0.15s, border-color 0.15s, transform 0.15s;++}++.footer-social a:hover { color: var(--accent); border-color: var(--accent); transform: translateY(-2px); }++.footer-bar {++ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px;++ margin-top: clamp(20px, 3vw, 30px); padding-top: 16px; border-top: 1px solid var(--border);++ /* The floating DM/chat buttons are pinned to the bottom corners. Reserve their width until++ the viewport is wide enough that the centred footer already clears them. */++ padding-inline: clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px);++}++.footer-copy { color: var(--text-muted); font-size: 0.78rem; }++.footer-legal-inline { display: flex; gap: 16px; }++.footer-legal-inline a { color: var(--text-muted); font-size: 0.78rem; text-decoration: none; }++.footer-legal-inline a:hover { color: var(--accent); }++@media (max-width: 900px) {++ .footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }++ .footer-col--brand { grid-column: 1 / -1; }++}++@media (max-width: 560px) {++ /* Clear the floating chat buttons so nothing is hidden behind them. */++ .footer { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); }++ .footer-cols { gap: 22px 16px; }++ .footer-bar { justify-content: center; text-align: center; padding-inline: 0; }++ .footer-legal-inline { justify-content: center; }}--.footer-content {-- max-width: 1400px; margin: 0 auto;-- display: flex; align-items: center; justify-content: center;-- gap: 10px; flex-wrap: wrap; font-size: 0.8rem;--}--.footer-brand { color: var(--accent); font-weight: 600; }--.footer-brand i { margin-right: 4px; }--.footer-content a { color: var(--text-muted); font-size: 1rem; transition: color 0.15s; }--.footer-content a:hover { color: var(--accent); }--.footer-divider { color: var(--border-light); font-size: 0.7rem; }--.footer-copy { color: var(--text-muted); font-size: 0.75rem; }/* ═══════════════════════════════════════════════════════════════RESPONSIVE@@@ -62,12 +62,12 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=164">++ <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=7">++ <link rel="stylesheet" href="/css/home-fx.css?v=8"><link rel="stylesheet" href="/css/home-tour.css?v=2"><link rel="stylesheet" href="/css/ov-guide.css?v=1"><link rel="stylesheet" href="/css/i18n-star.css?v=5">@@@ -5040,17 +5040,57 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- FOOTER --><!-- ═══════════════════════════════════════════════════════════ --><footer class="footer">-- <div class="footer-content">-- <span class="footer-brand"><span class="ov-mark" data-size="22"></span> OpenVibe.Live</span>-- <span class="footer-divider">·</span>-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank"><i class="fa-brands fa-github"></i></a>-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank"><i class="fa-brands fa-discord"></i></a>-- <span class="footer-divider">·</span>-- <span class="footer-copy">Open Source & Community Driven</span>-- <span class="footer-divider">·</span>-- <a href="/tos">Terms</a>-- <a href="/privacy">Privacy</a>-- <a href="/dmca">DMCA</a>++ <div class="footer-inner">++ <div class="footer-cols">++ <div class="footer-col footer-col--brand">++ <a class="footer-brand" href="/" onclick="return handleLinkClick(event, '/')"><span class="ov-mark" data-size="26"></span> <span>OpenVibe<strong>.Live</strong></span></a>++ <p class="footer-tagline">Free, open-source live streaming. Go live from a browser, OBS or a robot, restream everywhere, and keep your chat, emotes and clips in one place.</p>++ <div class="footer-social">++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener" title="OpenVibe.Live on GitHub" aria-label="GitHub"><i class="fa-brands fa-github"></i></a>++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" rel="noopener noreferrer" title="Join the OpenVibe Discord" aria-label="Discord"><i class="fa-brands fa-discord"></i></a>++ <a href="/sitemap.xml" title="Sitemap" aria-label="Sitemap"><i class="fa-solid fa-sitemap"></i></a>++ </div>++ </div>++ <nav class="footer-col" aria-label="Watch">++ <h3>Watch</h3>++ <a href="/" onclick="return handleLinkClick(event, '/')">Live streams</a>++ <a href="/vods" onclick="return handleLinkClick(event, '/vods')">Stream VODs</a>++ <a href="/clips" onclick="return handleLinkClick(event, '/clips')">Clips</a>++ <a href="/arena" onclick="return handleLinkClick(event, '/arena')">The Arena</a>++ <a href="/chat" onclick="return handleLinkClick(event, '/chat')">Global chat</a>++ </nav>++ <nav class="footer-col" aria-label="Create">++ <h3>Create</h3>++ <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Start streaming</a>++ <a href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();">Set up restreams</a>++ <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')">Streamer dashboard</a>++ <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')">Pastes</a>++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')">Documentation</a>++ </nav>++ <nav class="footer-col" aria-label="The OpenVibe network">++ <h3>Network</h3>++ <a href="https://openvibe.tools" target="_blank" rel="noopener">Free online tools</a>++ <a href="https://dev.openvibe.tools" target="_blank" rel="noopener">Developer tools</a>++ <a href="https://net.openvibe.tools" target="_blank" rel="noopener">Network diagnostics</a>++ <a href="https://img.openvibe.tools" target="_blank" rel="noopener">Image converter</a>++ <a href="https://openvibe.network" target="_blank" rel="noopener">Account & themes</a>++ </nav>++ <nav class="footer-col" aria-label="Legal">++ <h3>Legal</h3>++ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms of Service</a>++ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy Policy</a>++ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>++ <a href="/documentation#philosophy" onclick="return handleLinkClick(event, '/documentation')">What is this?</a>++ </nav>++ </div>++ <div class="footer-bar">++ <span class="footer-copy">Open source & community driven · no ads, no trackers, no investors</span>++ <span class="footer-legal-inline">++ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms</a>++ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>++ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>++ </span>++ </div></div></footer>@@@ -117,6 +117,7 @@ async function _pageMeta(routePath) {if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));++ if (p === '/arena') return _arenaMeta();return null;void bu;@@@ -156,6 +157,33 @@ function _channelMeta(username) {};}++// The Arena: a real content page (mic-judged trash talk), so give it real metadata.++function _arenaMeta() {++ let fighters = [], moments = [];++ try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }++ try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }++ const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };++ const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);++ const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';++ const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);++ const items = moments.slice(0, 10).map((mo, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(mo.text || 'Mic moment', 110) }));++ const snapshot = _detailSnapshot({++ title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,++ desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,++ transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',++ });++ return {++ title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',++ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',++ jsonLd: [++ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title, url: abs('/arena'), description: desc },++ ...(items.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest mic moments', itemListElement: items }] : []),++ _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),++ ],++ snapshot,++ };++}++// After-show report: /recap/:streamIdfunction _recapMeta(streamId) {let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }@@@ -213,6 +241,24 @@ async function _homeMeta() {const listEls = [...liveItems, ...vodItems, ...clipItems].slice(0, 20).map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) }));const jsonLd = [++ {++ '@context': 'https://schema.org', '@type': 'WebSite',++ name: SITE_NAME, url: baseUrl(), description: clean(description, 300),++ inLanguage: 'en', publisher: { '@id': `${baseUrl()}/#org` },++ },++ {++ '@context': 'https://schema.org', '@type': 'Organization', '@id': `${baseUrl()}/#org`,++ name: 'OpenVibe', url: baseUrl(), logo: DEFAULT_OG_IMAGE,++ description: 'A free, open-source network of live streaming, media and developer tools, run by its community with no ads and no investors.',++ // sameAs is how a search engine learns these properties are one brand rather than++ // unrelated sites that happen to share a name.++ sameAs: [++ 'https://github.com/OpenVibers',++ 'https://discord.gg/M6MuRUaeJj',++ 'https://openvibe.tools',++ 'https://openvibe.network',++ ],++ },{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },];return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };@@@ -425,7 +471,7 @@ function _cacheSet(key, html) {_cache.set(key, { html, at: Date.now() });}--const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;++const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;async function middleware(req, res, next) {if (req.method !== 'GET') return next();
@@ -32,12 +32,15 @@
@@ -32,12 +32,15 @@
box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);
box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);
transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;
transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
}
-/* Slow sheen that crosses each button in turn — the idle "these are buttons" tell. */
+/* One wave, not six blinks: every button shares --hb-wave, so they can never drift apart, and a
+ small per-button offset makes a single highlight travel across the row. */
+.hb { --hb-wave: 5s; --hb-step: 0.15s; }
.hb::before {
.hb::before {
- content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;
- background: linear-gradient(105deg, transparent 38%, color-mix(in srgb, var(--c) 26%, transparent) 50%, transparent 62%);
- background-size: 280% 100%; background-position: 220% 0;
- animation: hbSheen 9s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.7s);
+ content: ''; position: absolute; top: 0; bottom: 0; left: -65%; width: 55%; z-index: -1;
+ pointer-events: none; will-change: transform;
+ background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--c) 34%, transparent) 50%, transparent);
+ animation: hbWave var(--hb-wave) linear infinite;
+ animation-delay: calc(var(--i, 0) * var(--hb-step));
}
}
.hb::after {
.hb::after {
content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
@@ -52,7 +55,7 @@
@@ -52,7 +55,7 @@
border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);
border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);
box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;
transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;
- animation: hbBreathe 4.5s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.35s);
+ animation: hbPulse var(--hb-wave) ease-in-out infinite; animation-delay: calc(var(--i, 0) * var(--hb-step));
}
}
.hb-label { flex: 1; text-align: left; }
.hb-label { flex: 1; text-align: left; }
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
@@ -69,8 +72,8 @@
@@ -69,8 +72,8 @@
.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }
.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@keyframes hbOrbit { to { --a: 360deg; } }
@keyframes hbOrbit { to { --a: 360deg; } }
-@keyframes hbSheen { 0%, 62% { background-position: 220% 0; } 88%, 100% { background-position: -120% 0; } }
-@keyframes hbBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
+@keyframes hbWave { 0% { transform: translateX(0); } 22%, 100% { transform: translateX(330%); } }
+@keyframes hbPulse { 0%, 100% { transform: scale(1); } 7% { transform: scale(1.11); } 20% { transform: scale(1); } }
.hb-stream { --c: var(--accent); color: #fff;
.hb-stream { --c: var(--accent); color: #fff;
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);
border-color: color-mix(in srgb, var(--accent) 65%, transparent);
border-color: color-mix(in srgb, var(--accent) 65%, transparent);
@@ -116,7 +119,7 @@
@@ -116,7 +119,7 @@
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
-.rs-lite .hb::before { animation-duration: 14s; }
+.rs-lite .hb { --hb-wave: 8s; }
.rs-lite #page-home .stream-card { transition: none; }
.rs-lite #page-home .stream-card { transition: none; }
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
@@ -6136,19 +6136,63 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6136,19 +6136,63 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.footer {
.footer {
background: var(--bg-secondary);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
border-top: 1px solid var(--border);
- padding: 8px 16px;
+ /* The floating chat/DM buttons sit above the fold on phones — the old 8px footer put the
+ legal links underneath them, which is why Terms/Privacy/DMCA were cut off. */
+ padding: clamp(24px, 4vw, 40px) clamp(14px, 4vw, 32px) calc(clamp(20px, 3vw, 28px) + env(safe-area-inset-bottom, 0px));
+ margin-top: 40px;
+}
+.footer-inner { max-width: 1240px; margin: 0 auto; }
+.footer-cols {
+ display: grid; gap: clamp(20px, 3vw, 36px);
+ grid-template-columns: minmax(230px, 1.6fr) repeat(4, minmax(120px, 1fr));
+ align-items: start;
+}
+.footer-col { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
+.footer-col h3 {
+ margin: 0 0 2px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
+ text-transform: uppercase; color: var(--text-muted);
+}
+.footer-col a {
+ color: var(--text-secondary); font-size: 0.86rem; text-decoration: none; line-height: 1.4;
+ transition: color 0.15s, transform 0.15s; width: fit-content;
+}
+.footer-col a:hover { color: var(--accent); transform: translateX(2px); }
+.footer-brand {
+ display: inline-flex; align-items: center; gap: 9px; color: var(--text-primary);
+ font-size: 1.05rem; font-weight: 700; text-decoration: none; margin-bottom: 2px;
+}
+.footer-brand strong { color: var(--accent); font-weight: 800; }
+.footer-brand:hover { color: var(--text-primary); }
+.footer-tagline { margin: 0; font-size: 0.82rem; line-height: 1.55; color: var(--text-muted); max-width: 46ch; }
+.footer-social { display: flex; gap: 8px; margin-top: 4px; }
+.footer-social a {
+ width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center;
+ color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border);
+ font-size: 0.95rem; transition: color 0.15s, border-color 0.15s, transform 0.15s;
+}
+.footer-social a:hover { color: var(--accent); border-color: var(--accent); transform: translateY(-2px); }
+.footer-bar {
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px;
+ margin-top: clamp(20px, 3vw, 30px); padding-top: 16px; border-top: 1px solid var(--border);
+ /* The floating DM/chat buttons are pinned to the bottom corners. Reserve their width until
+ the viewport is wide enough that the centred footer already clears them. */
+ padding-inline: clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px);
+}
+.footer-copy { color: var(--text-muted); font-size: 0.78rem; }
+.footer-legal-inline { display: flex; gap: 16px; }
+.footer-legal-inline a { color: var(--text-muted); font-size: 0.78rem; text-decoration: none; }
+.footer-legal-inline a:hover { color: var(--accent); }
+@media (max-width: 900px) {
+ .footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .footer-col--brand { grid-column: 1 / -1; }
+}
+@media (max-width: 560px) {
+ /* Clear the floating chat buttons so nothing is hidden behind them. */
+ .footer { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); }
+ .footer-cols { gap: 22px 16px; }
+ .footer-bar { justify-content: center; text-align: center; padding-inline: 0; }
+ .footer-legal-inline { justify-content: center; }
}
}
-.footer-content {
- max-width: 1400px; margin: 0 auto;
- display: flex; align-items: center; justify-content: center;
- gap: 10px; flex-wrap: wrap; font-size: 0.8rem;
-}
-.footer-brand { color: var(--accent); font-weight: 600; }
-.footer-brand i { margin-right: 4px; }
-.footer-content a { color: var(--text-muted); font-size: 1rem; transition: color 0.15s; }
-.footer-content a:hover { color: var(--accent); }
-.footer-divider { color: var(--border-light); font-size: 0.7rem; }
-.footer-copy { color: var(--text-muted); font-size: 0.75rem; }
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
RESPONSIVE
RESPONSIVE
@@ -62,12 +62,12 @@
@@ -62,12 +62,12 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=164">
+ <link rel="stylesheet" href="/css/style.css?v=166">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/home-fx.css?v=7">
+ <link rel="stylesheet" href="/css/home-fx.css?v=8">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<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">
@@ -5040,17 +5040,57 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5040,17 +5040,57 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- FOOTER -->
<!-- FOOTER -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<footer class="footer">
<footer class="footer">
- <div class="footer-content">
- <span class="footer-brand"><span class="ov-mark" data-size="22"></span> OpenVibe.Live</span>
- <span class="footer-divider">·</span>
- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank"><i class="fa-brands fa-github"></i></a>
- <a href="https://discord.gg/M6MuRUaeJj" target="_blank"><i class="fa-brands fa-discord"></i></a>
- <span class="footer-divider">·</span>
- <span class="footer-copy">Open Source & Community Driven</span>
- <span class="footer-divider">·</span>
- <a href="/tos">Terms</a>
- <a href="/privacy">Privacy</a>
- <a href="/dmca">DMCA</a>
+ <div class="footer-inner">
+ <div class="footer-cols">
+ <div class="footer-col footer-col--brand">
+ <a class="footer-brand" href="/" onclick="return handleLinkClick(event, '/')"><span class="ov-mark" data-size="26"></span> <span>OpenVibe<strong>.Live</strong></span></a>
+ <p class="footer-tagline">Free, open-source live streaming. Go live from a browser, OBS or a robot, restream everywhere, and keep your chat, emotes and clips in one place.</p>
+ <div class="footer-social">
+ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener" title="OpenVibe.Live on GitHub" aria-label="GitHub"><i class="fa-brands fa-github"></i></a>
+ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" rel="noopener noreferrer" title="Join the OpenVibe Discord" aria-label="Discord"><i class="fa-brands fa-discord"></i></a>
+ <a href="/sitemap.xml" title="Sitemap" aria-label="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
+ </div>
+ </div>
+ <nav class="footer-col" aria-label="Watch">
+ <h3>Watch</h3>
+ <a href="/" onclick="return handleLinkClick(event, '/')">Live streams</a>
+ <a href="/vods" onclick="return handleLinkClick(event, '/vods')">Stream VODs</a>
+ <a href="/clips" onclick="return handleLinkClick(event, '/clips')">Clips</a>
+ <a href="/arena" onclick="return handleLinkClick(event, '/arena')">The Arena</a>
+ <a href="/chat" onclick="return handleLinkClick(event, '/chat')">Global chat</a>
+ </nav>
+ <nav class="footer-col" aria-label="Create">
+ <h3>Create</h3>
+ <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Start streaming</a>
+ <a href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();">Set up restreams</a>
+ <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')">Streamer dashboard</a>
+ <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')">Pastes</a>
+ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')">Documentation</a>
+ </nav>
+ <nav class="footer-col" aria-label="The OpenVibe network">
+ <h3>Network</h3>
+ <a href="https://openvibe.tools" target="_blank" rel="noopener">Free online tools</a>
+ <a href="https://dev.openvibe.tools" target="_blank" rel="noopener">Developer tools</a>
+ <a href="https://net.openvibe.tools" target="_blank" rel="noopener">Network diagnostics</a>
+ <a href="https://img.openvibe.tools" target="_blank" rel="noopener">Image converter</a>
+ <a href="https://openvibe.network" target="_blank" rel="noopener">Account & themes</a>
+ </nav>
+ <nav class="footer-col" aria-label="Legal">
+ <h3>Legal</h3>
+ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms of Service</a>
+ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy Policy</a>
+ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
+ <a href="/documentation#philosophy" onclick="return handleLinkClick(event, '/documentation')">What is this?</a>
+ </nav>
+ </div>
+ <div class="footer-bar">
+ <span class="footer-copy">Open source & community driven · no ads, no trackers, no investors</span>
+ <span class="footer-legal-inline">
+ <a href="/tos" onclick="return handleLinkClick(event, '/tos')">Terms</a>
+ <a href="/privacy" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
+ <a href="/dmca" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
+ </span>
+ </div>
</div>
</div>
</footer>
</footer>
@@ -117,6 +117,7 @@ async function _pageMeta(routePath) {
@@ -117,6 +117,7 @@ async function _pageMeta(routePath) {
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return _pasteMeta(m[1]);
if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);
if ((m = p.match(/^\/@([A-Za-z0-9_.-]+)$/))) return _channelMeta(m[1]);
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
+ if (p === '/arena') return _arenaMeta();
return null;
return null;
void bu;
void bu;
@@ -156,6 +157,33 @@ function _channelMeta(username) {
@@ -156,6 +157,33 @@ function _channelMeta(username) {
};
};
}
}
+// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
+function _arenaMeta() {
+ let fighters = [], moments = [];
+ try { fighters = (require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }
+ try { moments = require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }
+ const nameOf = (id) => { try { return require('../arena/mic').nameOf(id); } catch { return null; } };
+ const names = fighters.slice(0, 8).map(nameOf).filter(Boolean);
+ const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';
+ const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);
+ const items = moments.slice(0, 10).map((mo, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(mo.text || 'Mic moment', 110) }));
+ const snapshot = _detailSnapshot({
+ title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,
+ desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,
+ transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',
+ });
+ return {
+ title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',
+ image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
+ jsonLd: [
+ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: title, url: abs('/arena'), description: desc },
+ ...(items.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest mic moments', itemListElement: items }] : []),
+ _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),
+ ],
+ snapshot,
+ };
+}
+
// After-show report: /recap/:streamId
// After-show report: /recap/:streamId
function _recapMeta(streamId) {
function _recapMeta(streamId) {
let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
let r; try { r = require('../recap/recap').getRecap(streamId); } catch { r = null; }
@@ -213,6 +241,24 @@ async function _homeMeta() {
@@ -213,6 +241,24 @@ async function _homeMeta() {
const listEls = [...liveItems, ...vodItems, ...clipItems].slice(0, 20)
const listEls = [...liveItems, ...vodItems, ...clipItems].slice(0, 20)
.map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) }));
.map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) }));
const jsonLd = [
const jsonLd = [
+ {
+ '@context': 'https://schema.org', '@type': 'WebSite',
+ name: SITE_NAME, url: baseUrl(), description: clean(description, 300),
+ inLanguage: 'en', publisher: { '@id': `${baseUrl()}/#org` },
+ },
+ {
+ '@context': 'https://schema.org', '@type': 'Organization', '@id': `${baseUrl()}/#org`,
+ name: 'OpenVibe', url: baseUrl(), logo: DEFAULT_OG_IMAGE,
+ description: 'A free, open-source network of live streaming, media and developer tools, run by its community with no ads and no investors.',
+ // sameAs is how a search engine learns these properties are one brand rather than
+ // unrelated sites that happen to share a name.
+ sameAs: [
+ 'https://github.com/OpenVibers',
+ 'https://discord.gg/M6MuRUaeJj',
+ 'https://openvibe.tools',
+ 'https://openvibe.network',
+ ],
+ },
{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
];
];
return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };
return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot };
@@ -425,7 +471,7 @@ function _cacheSet(key, html) {
@@ -425,7 +471,7 @@ function _cacheSet(key, html) {
_cache.set(key, { html, at: Date.now() });
_cache.set(key, { html, at: Date.now() });
}
}
-const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
+const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
async function middleware(req, res, next) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
if (req.method !== 'GET') return next();