openvibers/openvibe.live Commits
Fix: hero stat board rendered nothing (temporal dead zone on baselineDelta)
Changes
@@@ -227,3 +227,64 @@.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }.star-why-meta i { margin-right: 4px; }++++/* ── Compact Star card ───────────────────────────────────────────────────────++ The section opened as a full-width feature panel: big avatar, kicker, name, chips, a reason,++ a bio in two languages, an AI overview, a last-live note and — when they were on — a whole++ stream card. That is a lot of front page for one streamer. Collapsed it is now just who they++ are and the button to go watch them; everything else is one tap away, and the card animates++ between the two heights rather than jumping. */++.star-card.is-compact { padding: 14px 16px 16px; gap: 14px; align-items: center; }++.star-card.is-compact .star-avatar-wrap { width: 68px; height: 68px; }++.star-card.is-compact .star-avatar { width: 68px; height: 68px; font-size: 1.5rem; }++.star-card.is-compact .star-kicker { font-size: 0.6rem; margin-bottom: 2px; }++.star-card.is-compact .star-name { font-size: clamp(1.05rem, 3.4vw, 1.35rem); margin: 0 0 6px; }++.star-card.is-compact .star-chips { margin-bottom: 10px; }++.star-card.is-compact .star-petals { display: none; }++.star-card.is-compact .star-actions { margin-top: 0; }++.star-card.is-compact .star-btn-watch { font-size: 0.86rem; padding: 8px 15px; }++++/* The disclosure. Fades up as the card grows — see animateHeightChange(). */++.star-more { display: block; }++.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }++@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }++.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }++++.star-expand {++ display: inline-flex; align-items: center; gap: 8px;++ padding: 7px 14px 7px 8px; border-radius: 999px; cursor: pointer; font: inherit;++ font-size: 0.78rem; font-weight: 750;++ color: color-mix(in srgb, #fbbf24 80%, var(--text-primary));++ background: color-mix(in srgb, #fbbf24 10%, transparent);++ border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);++ transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;++}++.star-expand:hover {++ transform: translateY(-1px);++ background: color-mix(in srgb, #fbbf24 18%, transparent);++ border-color: color-mix(in srgb, #fbbf24 55%, transparent);++ box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 22%, transparent);++}++.star-expand:active { transform: translateY(0) scale(0.985); }++.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }++.star-expand i {++ width: 20px; height: 20px; flex: none; border-radius: 50%; display: grid; place-items: center;++ font-size: 0.58rem; color: #1a1506; line-height: 1;++ background: linear-gradient(140deg, #fbbf24, #f59e0b);++ transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);++}++.star-expand.is-open i { transform: rotate(180deg); }++.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44vw; }++++@media (max-width: 640px) {++ .star-card.is-compact { padding: 12px 13px 14px; gap: 11px; grid-template-columns: auto 1fr; }++ .star-card.is-compact .star-avatar-wrap, .star-card.is-compact .star-avatar { width: 54px; height: 54px; }++ .star-card.is-compact .star-avatar { font-size: 1.2rem; }++ .star-card.is-compact .star-actions { flex-wrap: wrap; gap: 7px; }++ .star-expand { font-size: 0.72rem; padding: 6px 11px 6px 6px; }++}++@media (prefers-reduced-motion: reduce) {++ .star-more.is-entering { animation: none; }++ .star-card.is-swapping { transition: none; }++ .star-expand, .star-expand i { transition: none; }++}@@@ -15279,3 +15279,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.home-cta-features { grid-template-columns: 1fr; gap: 9px; }.home-cta-feature { padding: 13px 14px; }}++++/* Once the two explainer sections have been demoted to the bottom of the page they are reference++ material rather than a pitch, so they get a quieter top margin and a rule separating them from++ the content above. */++.home-intro-demoted { margin-top: 26px; }++.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }@@@ -88,10 +88,10 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=193">++ <link rel="stylesheet" href="/css/style.css?v=194"><link rel="stylesheet" href="/css/home-fx.css?v=13"><link rel="stylesheet" href="/css/home-tour.css?v=7">-- <link rel="stylesheet" href="/css/i18n-star.css?v=5">++ <link rel="stylesheet" href="/css/i18n-star.css?v=6"><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">@@@ -5131,7 +5131,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/live-notify.js?v=3" defer></script><script src="/js/ov-skeleton.js?v=1" defer></script><script src="/js/ov-odometer.js?v=3" defer></script>--<script src="/js/app.js?v=162" defer></script>++<script src="/js/app.js?v=164" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1325,6 +1325,37 @@ function startHeroQuips(quips) {}// ── Hero stats bar (animated count-up) ──────────────────────────++/**++ * Run a DOM change and animate the container from its old height to its new one.++ *++ * Expanding or collapsing a panel by toggling `hidden` makes everything below it jump by however++ * tall the panel is. Measuring before and after, pinning the old height and transitioning to the++ * new one turns that into a movement the eye can follow. The height is pinned only for the length++ * of the animation, so the panel can still grow freely afterwards.++ *++ * `entering` is the block being revealed; it fades up while the height changes, so the swap reads++ * as one movement rather than a resize plus a pop.++ */++function animateHeightChange(wrap, mutate, opts = {}) {++ const { duration = 420, entering = null } = opts;++ const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++ if (!wrap || reduced) { try { mutate(); } catch { /* */ } return; }++ const h0 = wrap.offsetHeight;++ try { mutate(); } catch { /* */ }++ const h1 = wrap.offsetHeight;++ if (h0 === h1) return;++ clearTimeout(wrap._ovHeightTimer);++ wrap.classList.add('is-swapping');++ wrap.style.height = `${h0}px`;++ if (entering) { entering.classList.remove('is-entering'); void entering.offsetWidth; entering.classList.add('is-entering'); }++ requestAnimationFrame(() => { wrap.style.height = `${h1}px`; });++ wrap._ovHeightTimer = setTimeout(() => {++ wrap.style.height = '';++ wrap.classList.remove('is-swapping');++ if (entering) entering.classList.remove('is-entering');++ }, duration + 40);++}++function _baselineDeltaHTML(now, avg, peak, window = '24h') {const n = Number(now) || 0;const a = Number(avg);@@@ -1369,8 +1400,8 @@ function renderHeroStats(stats) {// ── Right now ────────────────────────────────────────────────const now = [-- { key: 'liveNow', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },-- { key: 'viewersNow', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },++ { key: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },++ { key: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },];@@@ -1465,7 +1496,6 @@ function renderHeroStats(stats) {* five-minute sampler has a week of history to compare against. Averages ignore samples where* nothing was happening, so an empty night doesn't make every afternoon look like a record.*/-- const baselineDelta = (now, avg, peak, window = '24h') => _baselineDeltaHTML(now, avg, peak, window);const recDelta = (rec, u = '') => {if (!rec) return '';const w = Number(rec.w), pw = Number(rec.pw);@@@ -1502,8 +1532,8 @@ function renderHeroStats(stats) {// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.const CC = stats.concurrency || {};const HEADLINE = [-- { key: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },-- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },++ { key: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },++ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },@@@ -2187,7 +2217,44 @@ function _celebrateEgg(egg) {} catch { /* */ }}++/**++ * Demote the two explainer sections once someone has read them enough times.++ *++ * "One stream. Everywhere." and the about panel are the pitch: they exist to tell a first-time++ * visitor what this place is. By the twentieth visit they are furniture between the reader and++ * the streams, and the reader has long since stopped looking at them — but they still shouldn't++ * disappear, because someone may want to send the link to a friend or read the money section.++ *++ * So after 18 home page views they move to the bottom of the page instead, ahead of the++ * changelog and the legal row. Same content, out of the way. The count lives per-browser, and a++ * cleared cache simply means someone sees the pitch again, which is harmless.++ */++const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;++function _homeIntroViews(bump) {++ try {++ const n = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;++ if (bump) localStorage.setItem('ov_home_views', String(Math.min(n + 1, 9999)));++ return n;++ } catch { return 0; }++}++function demoteHomeIntroSections() {++ const views = _homeIntroViews(true);++ if (views < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;++ const container = document.querySelector('#page-home .container');++ const anchorEl = document.getElementById('home-changelog-wrapper');++ if (!container || !anchorEl) return;++ for (const id of ['home-tour-mount', 'home-cta-banner']) {++ const el = document.getElementById(id);++ // Already moved (a re-render, or a second visit in the same session)? Leave it be.++ if (!el || el.dataset.ovDemoted) continue;++ el.dataset.ovDemoted = '1';++ el.classList.add('home-intro-demoted');++ try { container.insertBefore(el, anchorEl); } catch { /* */ }++ }++}++async function loadHome() {++ demoteHomeIntroSections();void loadHomeChangelog();_initHomeAbout();updateNavHeroTransparency(); // transparent nav over the hero at the top@@@ -9563,17 +9630,43 @@ async function loadHomeStar() {<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div><h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3><div class="star-chips">${chips}</div>-- ${why}-- ${bioHtml}-- ${overview}-- ${offline}++ <div class="star-more" id="star-more" hidden>++ ${why}++ ${bioHtml}++ ${overview}++ ${offline}++ </div><div class="star-actions"><a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a><a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>++ <button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">++ <i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>++ </button></div></div>-- ${liveCard}</div></div>`;++ // The live stream card is the tallest piece of the section — it goes inside the disclosure.++ const more = sec.querySelector('#star-more');++ if (more && liveCard) more.insertAdjacentHTML('beforeend', liveCard);++++ const card = sec.querySelector('.star-card');++ const btn = sec.querySelector('#star-expand');++ if (card && btn && more) {++ card.classList.add('is-compact');++ const label = () => {++ btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Less';++ btn.classList.toggle('is-open', !more.hidden);++ btn.setAttribute('aria-expanded', String(!more.hidden));++ };++ label();++ btn.addEventListener('click', () => {++ animateHeightChange(card, () => {++ more.hidden = !more.hidden;++ card.classList.toggle('is-compact', more.hidden);++ label();++ }, { entering: more.hidden ? null : more });++ });++ }sec.style.display = '';}
@@ -227,3 +227,64 @@
@@ -227,3 +227,64 @@
.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }
.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }
.star-why-meta i { margin-right: 4px; }
.star-why-meta i { margin-right: 4px; }
+
+/* ── Compact Star card ───────────────────────────────────────────────────────
+ The section opened as a full-width feature panel: big avatar, kicker, name, chips, a reason,
+ a bio in two languages, an AI overview, a last-live note and — when they were on — a whole
+ stream card. That is a lot of front page for one streamer. Collapsed it is now just who they
+ are and the button to go watch them; everything else is one tap away, and the card animates
+ between the two heights rather than jumping. */
+.star-card.is-compact { padding: 14px 16px 16px; gap: 14px; align-items: center; }
+.star-card.is-compact .star-avatar-wrap { width: 68px; height: 68px; }
+.star-card.is-compact .star-avatar { width: 68px; height: 68px; font-size: 1.5rem; }
+.star-card.is-compact .star-kicker { font-size: 0.6rem; margin-bottom: 2px; }
+.star-card.is-compact .star-name { font-size: clamp(1.05rem, 3.4vw, 1.35rem); margin: 0 0 6px; }
+.star-card.is-compact .star-chips { margin-bottom: 10px; }
+.star-card.is-compact .star-petals { display: none; }
+.star-card.is-compact .star-actions { margin-top: 0; }
+.star-card.is-compact .star-btn-watch { font-size: 0.86rem; padding: 8px 15px; }
+
+/* The disclosure. Fades up as the card grows — see animateHeightChange(). */
+.star-more { display: block; }
+.star-more.is-entering { animation: starMoreIn 0.42s cubic-bezier(.2, 1, .3, 1); }
+@keyframes starMoreIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
+.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
+
+.star-expand {
+ display: inline-flex; align-items: center; gap: 8px;
+ padding: 7px 14px 7px 8px; border-radius: 999px; cursor: pointer; font: inherit;
+ font-size: 0.78rem; font-weight: 750;
+ color: color-mix(in srgb, #fbbf24 80%, var(--text-primary));
+ background: color-mix(in srgb, #fbbf24 10%, transparent);
+ border: 1px solid color-mix(in srgb, #fbbf24 30%, transparent);
+ transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
+}
+.star-expand:hover {
+ transform: translateY(-1px);
+ background: color-mix(in srgb, #fbbf24 18%, transparent);
+ border-color: color-mix(in srgb, #fbbf24 55%, transparent);
+ box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 22%, transparent);
+}
+.star-expand:active { transform: translateY(0) scale(0.985); }
+.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }
+.star-expand i {
+ width: 20px; height: 20px; flex: none; border-radius: 50%; display: grid; place-items: center;
+ font-size: 0.58rem; color: #1a1506; line-height: 1;
+ background: linear-gradient(140deg, #fbbf24, #f59e0b);
+ transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
+}
+.star-expand.is-open i { transform: rotate(180deg); }
+.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44vw; }
+
+@media (max-width: 640px) {
+ .star-card.is-compact { padding: 12px 13px 14px; gap: 11px; grid-template-columns: auto 1fr; }
+ .star-card.is-compact .star-avatar-wrap, .star-card.is-compact .star-avatar { width: 54px; height: 54px; }
+ .star-card.is-compact .star-avatar { font-size: 1.2rem; }
+ .star-card.is-compact .star-actions { flex-wrap: wrap; gap: 7px; }
+ .star-expand { font-size: 0.72rem; padding: 6px 11px 6px 6px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .star-more.is-entering { animation: none; }
+ .star-card.is-swapping { transition: none; }
+ .star-expand, .star-expand i { transition: none; }
+}
@@ -15279,3 +15279,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15279,3 +15279,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-feature { padding: 13px 14px; }
.home-cta-feature { padding: 13px 14px; }
}
}
+
+/* Once the two explainer sections have been demoted to the bottom of the page they are reference
+ material rather than a pitch, so they get a quieter top margin and a rule separating them from
+ the content above. */
+.home-intro-demoted { margin-top: 26px; }
+.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }
@@ -88,10 +88,10 @@
@@ -88,10 +88,10 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=193">
+ <link rel="stylesheet" href="/css/style.css?v=194">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
- <link rel="stylesheet" href="/css/i18n-star.css?v=5">
+ <link rel="stylesheet" href="/css/i18n-star.css?v=6">
<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/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/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
@@ -5131,7 +5131,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5131,7 +5131,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
-<script src="/js/app.js?v=162" defer></script>
+<script src="/js/app.js?v=164" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1325,6 +1325,37 @@ function startHeroQuips(quips) {
@@ -1325,6 +1325,37 @@ function startHeroQuips(quips) {
}
}
// ── Hero stats bar (animated count-up) ──────────────────────────
// ── Hero stats bar (animated count-up) ──────────────────────────
+/**
+ * Run a DOM change and animate the container from its old height to its new one.
+ *
+ * Expanding or collapsing a panel by toggling `hidden` makes everything below it jump by however
+ * tall the panel is. Measuring before and after, pinning the old height and transitioning to the
+ * new one turns that into a movement the eye can follow. The height is pinned only for the length
+ * of the animation, so the panel can still grow freely afterwards.
+ *
+ * `entering` is the block being revealed; it fades up while the height changes, so the swap reads
+ * as one movement rather than a resize plus a pop.
+ */
+function animateHeightChange(wrap, mutate, opts = {}) {
+ const { duration = 420, entering = null } = opts;
+ const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (!wrap || reduced) { try { mutate(); } catch { /* */ } return; }
+ const h0 = wrap.offsetHeight;
+ try { mutate(); } catch { /* */ }
+ const h1 = wrap.offsetHeight;
+ if (h0 === h1) return;
+ clearTimeout(wrap._ovHeightTimer);
+ wrap.classList.add('is-swapping');
+ wrap.style.height = `${h0}px`;
+ if (entering) { entering.classList.remove('is-entering'); void entering.offsetWidth; entering.classList.add('is-entering'); }
+ requestAnimationFrame(() => { wrap.style.height = `${h1}px`; });
+ wrap._ovHeightTimer = setTimeout(() => {
+ wrap.style.height = '';
+ wrap.classList.remove('is-swapping');
+ if (entering) entering.classList.remove('is-entering');
+ }, duration + 40);
+}
+
function _baselineDeltaHTML(now, avg, peak, window = '24h') {
function _baselineDeltaHTML(now, avg, peak, window = '24h') {
const n = Number(now) || 0;
const n = Number(now) || 0;
const a = Number(avg);
const a = Number(avg);
@@ -1369,8 +1400,8 @@ function renderHeroStats(stats) {
@@ -1369,8 +1400,8 @@ function renderHeroStats(stats) {
// ── Right now ────────────────────────────────────────────────
// ── Right now ────────────────────────────────────────────────
const now = [
const now = [
- { key: 'liveNow', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
- { key: 'viewersNow', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
+ { key: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
+ { key: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },
];
];
@@ -1465,7 +1496,6 @@ function renderHeroStats(stats) {
@@ -1465,7 +1496,6 @@ function renderHeroStats(stats) {
* five-minute sampler has a week of history to compare against. Averages ignore samples where
* five-minute sampler has a week of history to compare against. Averages ignore samples where
* nothing was happening, so an empty night doesn't make every afternoon look like a record.
* nothing was happening, so an empty night doesn't make every afternoon look like a record.
*/
*/
- const baselineDelta = (now, avg, peak, window = '24h') => _baselineDeltaHTML(now, avg, peak, window);
const recDelta = (rec, u = '') => {
const recDelta = (rec, u = '') => {
if (!rec) return '';
if (!rec) return '';
const w = Number(rec.w), pw = Number(rec.pw);
const w = Number(rec.w), pw = Number(rec.pw);
@@ -1502,8 +1532,8 @@ function renderHeroStats(stats) {
@@ -1502,8 +1532,8 @@ function renderHeroStats(stats) {
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
const CC = stats.concurrency || {};
const CC = stats.concurrency || {};
const HEADLINE = [
const HEADLINE = [
- { key: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
+ { key: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
+ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
@@ -2187,7 +2217,44 @@ function _celebrateEgg(egg) {
@@ -2187,7 +2217,44 @@ function _celebrateEgg(egg) {
} catch { /* */ }
} catch { /* */ }
}
}
+/**
+ * Demote the two explainer sections once someone has read them enough times.
+ *
+ * "One stream. Everywhere." and the about panel are the pitch: they exist to tell a first-time
+ * visitor what this place is. By the twentieth visit they are furniture between the reader and
+ * the streams, and the reader has long since stopped looking at them — but they still shouldn't
+ * disappear, because someone may want to send the link to a friend or read the money section.
+ *
+ * So after 18 home page views they move to the bottom of the page instead, ahead of the
+ * changelog and the legal row. Same content, out of the way. The count lives per-browser, and a
+ * cleared cache simply means someone sees the pitch again, which is harmless.
+ */
+const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;
+function _homeIntroViews(bump) {
+ try {
+ const n = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;
+ if (bump) localStorage.setItem('ov_home_views', String(Math.min(n + 1, 9999)));
+ return n;
+ } catch { return 0; }
+}
+function demoteHomeIntroSections() {
+ const views = _homeIntroViews(true);
+ if (views < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
+ const container = document.querySelector('#page-home .container');
+ const anchorEl = document.getElementById('home-changelog-wrapper');
+ if (!container || !anchorEl) return;
+ for (const id of ['home-tour-mount', 'home-cta-banner']) {
+ const el = document.getElementById(id);
+ // Already moved (a re-render, or a second visit in the same session)? Leave it be.
+ if (!el || el.dataset.ovDemoted) continue;
+ el.dataset.ovDemoted = '1';
+ el.classList.add('home-intro-demoted');
+ try { container.insertBefore(el, anchorEl); } catch { /* */ }
+ }
+}
+
async function loadHome() {
async function loadHome() {
+ demoteHomeIntroSections();
void loadHomeChangelog();
void loadHomeChangelog();
_initHomeAbout();
_initHomeAbout();
updateNavHeroTransparency(); // transparent nav over the hero at the top
updateNavHeroTransparency(); // transparent nav over the hero at the top
@@ -9563,17 +9630,43 @@ async function loadHomeStar() {
@@ -9563,17 +9630,43 @@ async function loadHomeStar() {
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<div class="star-chips">${chips}</div>
<div class="star-chips">${chips}</div>
- ${why}
- ${bioHtml}
- ${overview}
- ${offline}
+ <div class="star-more" id="star-more" hidden>
+ ${why}
+ ${bioHtml}
+ ${overview}
+ ${offline}
+ </div>
<div class="star-actions">
<div class="star-actions">
<a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
<a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
<a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
<a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
+ <button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">
+ <i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>
+ </button>
</div>
</div>
</div>
</div>
- ${liveCard}
</div>
</div>
</div>`;
</div>`;
+ // The live stream card is the tallest piece of the section — it goes inside the disclosure.
+ const more = sec.querySelector('#star-more');
+ if (more && liveCard) more.insertAdjacentHTML('beforeend', liveCard);
+
+ const card = sec.querySelector('.star-card');
+ const btn = sec.querySelector('#star-expand');
+ if (card && btn && more) {
+ card.classList.add('is-compact');
+ const label = () => {
+ btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Less';
+ btn.classList.toggle('is-open', !more.hidden);
+ btn.setAttribute('aria-expanded', String(!more.hidden));
+ };
+ label();
+ btn.addEventListener('click', () => {
+ animateHeightChange(card, () => {
+ more.hidden = !more.hidden;
+ card.classList.toggle('is-compact', more.hidden);
+ label();
+ }, { entering: more.hidden ? null : more });
+ });
+ }
sec.style.display = '';
sec.style.display = '';
}
}