openvibers/openvibe.live Commits
Topics live now: filter the live grid by what streams are about (#520)
Changes
@@@ -445,7 +445,35 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }@media (max-width: 560px) {.home-changelog { padding: 12px 13px 8px; }}--.stream-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; }++.stream-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; min-width: 0; }++/* Two of a live stream's topics beside its category, in quiet words; the metaright never wraps under them. */++.stream-card-topics { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 0.72rem; }++.stream-card-metaright { flex-shrink: 0; }++.stream-grid > .stream-card[hidden] { display: none; }++++/* Topics live now: one sideways row of chips over the Live Now grid (rails, never a fold). */++.home-topics {++ display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;++ margin: -4px 0 14px; padding: 2px 2px 4px;++ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);++ mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);++}++.home-topics[hidden] { display: none; }++.home-topics::-webkit-scrollbar { display: none; }++.home-topic {++ flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px;++ min-height: 34px; padding: 0 13px;++ border: 1px solid var(--border); border-radius: 999px;++ background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent);++ color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; white-space: nowrap; cursor: pointer;++ transition: background var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease), border-color var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease), color var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease);++}++.home-topic:hover { border-color: var(--accent); color: var(--text-primary); }++.home-topic:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }++.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }++.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }++@media (pointer: coarse) { .home-topic { min-height: 44px; } }.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }.stream-card-appear { animation: streamCardAppear var(--ov-dur-4, 0.6s) var(--ov-ease-out, ease); }@@@ -9658,9 +9658,10 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }border: 1px solid color-mix(in srgb, var(--sc-on) 34%, transparent);box-shadow: 0 0 16px color-mix(in srgb, var(--sc-on) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);}--/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. */++/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. Its size is the glyph's++ own 1.15em: "auto" let the shared SVG glyph (ov-glyphs) fill the heading, a 300 px red disc on a desktop. */.section-header h2 > i.live-dot {-- width: auto; height: auto; background: none; border: 0; box-shadow: none;++ width: 1.15em; height: 1.15em; background: none; border: 0; box-shadow: none;color: var(--live-red-text, var(--live-red)); font-size: 0.62rem;}/* A rule that carries the section colour out across the page and fades. */@@@ -80,6 +80,9 @@<span id="live-count" class="badge">0</span><button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button></div>++ <!-- Topics live now (app-home.js _homeTopicsSync): the live streams' categories and tags as filters over the++ grid below; filled and shown only when two streams or more are live with two topics or more. -->++ <div class="home-topics" id="home-topics" role="group" aria-label="Filter live streams by topic" hidden></div><div class="stream-grid" id="stream-grid-live"><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>@@@ -1395,6 +1395,7 @@ async function loadHome() {const noLiveEl = document.getElementById('no-live-streams');if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';renderStreamGrid('stream-grid-live', streams, true);++ _homeTopicsSync(streams);_homeFeaturedSync(streams.length > 0);if (window.homeFeatured && homeFeatured.markGrid) homeFeatured.markGrid();}, { ttl: 20000 });@@@ -1759,6 +1760,82 @@ function _thumbTint(s) {return `hsl(${h} 45% 34%)`;}++/* ── Topics: what the live streams are about, as filters over the Live Now grid ─────────────++ A stream's topics are its category, the tags its streamer set and the tags the AI read from the++ stream (streams.ai_tags), minus words that say nothing to a viewer here: every stream is live,++ streamed and on a camera or a screen. Live market outcome 2: a viewer picks a topic, the grid++ keeps the streams that have it; nothing else on the page changes. */++const _TOPIC_STOP = new Set(['live', 'live stream', 'livestream', 'stream', 'streaming', 'streamer', 'chat', 'livechat',++ 'live chat', 'overlay', 'ui', 'setup', 'lobby', 'facecam', 'multicam', 'multiframe', 'webcam', 'camera', 'screen',++ 'desktop', 'browser', 'obs', 'gameplay', 'commentary', 'surveillance', 'stream overlay', 'pc']);++function _streamTopics(s) {++ const out = [];++ const add = (t) => {++ const v = String(t || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();++ if (v && v.length <= 32 && !_TOPIC_STOP.has(v) && !out.includes(v)) out.push(v);++ };++ add(s && s.category);++ for (const field of [s && s.tags, s && s.ai_tags]) {++ let list = field;++ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = null; } }++ if (Array.isArray(list)) list.forEach(add);++ }++ return out;++}++++let _homeTopic = '';++/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more. */++function _homeTopicsSync(streams) {++ const row = document.getElementById('home-topics');++ if (!row) return;++ const counts = new Map();++ for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);++ const show = streams.length >= 2 && counts.size >= 2;++ row.hidden = !show;++ if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }++ if (_homeTopic && !counts.has(_homeTopic)) _homeTopic = '';++ // Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.++ const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));++ const key = `${streams.length}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;++ if (row.dataset.key !== key) {++ row.dataset.key = key;++ const chip = (topic, label, n) => {++ const b = document.createElement('button');++ b.type = 'button';++ b.className = 'home-topic';++ b.dataset.topic = topic;++ b.append(label);++ const c = document.createElement('span');++ c.className = 'home-topic-n';++ c.textContent = String(n);++ b.append(' ', c);++ return b;++ };++ const nodes = [chip('', 'All', streams.length)];++ for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));++ row.replaceChildren(...nodes);++ if (!row._ovBound) {++ row._ovBound = true;++ row.addEventListener('click', (e) => {++ const b = e.target.closest('.home-topic');++ if (!b) return;++ _homeTopic = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;++ _homeTopicsApply();++ });++ }++ }++ _homeTopicsApply();++}++function _homeTopicsApply() {++ const row = document.getElementById('home-topics');++ if (row) row.querySelectorAll('.home-topic').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.topic === _homeTopic)));++ const grid = document.getElementById('stream-grid-live');++ if (!grid) return;++ grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {++ card.hidden = !!_homeTopic && !(card.dataset.topics || '').split('|').includes(_homeTopic);++ });++}++function streamCardHTML(s, isLive) {let navUrl;if (isLive && s.id) {@@@ -1772,8 +1849,11 @@ function streamCardHTML(s, isLive) {const thumb = (!isLive && s.vod_thumbnail_url) ? s.vod_thumbnail_url : s.thumbnail_url;const duration = !isLive && s.vod_duration ? `<span class="stream-card-duration">${formatDuration(s.vod_duration)}</span>` : '';const endedAgo = !isLive && s.ended_at ? `<span class="stream-card-ago">${timeAgo(s.ended_at)}</span>` : '';++ const topics = isLive ? _streamTopics(s) : [];++ const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();++ const shown = topics.filter((t) => t !== cat).slice(0, 2);return `-- <a class="stream-card" data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">++ <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')"><div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}${streamTypeBadge(s.browser_mode, s.streaming_method)}@@@ -1791,6 +1871,7 @@ function streamCardHTML(s, isLive) {${_cardAiHTML(s.ai_overview_short, s.ai_overview)}<div class="stream-card-meta">${s.category ? `<span class="stream-card-tag"${_catInferred(s) ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${esc(_capTag(s.category))}${_catInferred(s) ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}++ ${shown.length ? `<span class="stream-card-topics">${shown.map((t) => esc(_capTag(t))).join(' · ')}</span>` : ''}<span class="stream-card-metaright">${isLive && s.started_at ? `<span class="stream-card-uptime" data-since="${esc(s.started_at)}"><i class="fa-solid fa-clock"></i> ${formatUptime(s.started_at)}</span>` : ''}${isLive ? `<span class="stream-card-vcount"><i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}</span>` : ''}@@@ -1856,6 +1937,8 @@ function _crossfadeThumb(imgEl, newSrc) {}function _updateLiveCard(card, s) {++ // The AI re-reads a stream as it goes, so its topics can change between refreshes.++ card.dataset.topics = _streamTopics(s).join('|');const vc = card.querySelector('.stream-card-vcount');if (vc) vc.innerHTML = `<i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}`;const up = card.querySelector('.stream-card-uptime');@@@ -1995,6 +2078,7 @@ async function refreshHomeLive() {setTimeout(() => { try { el.remove(); } catch {} }, 420);}});++ _homeTopicsSync(streams);}// Refresh the paginated home sections (only at page 1, only when visible — so wennew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// Topics live now (Live market outcome 2): the live streams' categories and tags, the AI's included, as one row of++// filters over the Live Now grid. The functions are public/js/app-home.js's own, run here against a small fake DOM.++// node test/home-topics.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');++const start = src.indexOf('const _TOPIC_STOP');++const end = src.indexOf('function streamCardHTML(');++assert.ok(start > 0 && end > start, 'app-home.js has the topics block before the card');++const block = src.slice(start, end);++++// A fake document: the row and the grid's cards, enough for _homeTopicsSync and _homeTopicsApply.++function fakeDom(cardTopics) {++ const mk = (extra) => Object.assign({ hidden: false, dataset: {}, attrs: {}, children: [],++ setAttribute(k, v) { this.attrs[k] = v; }, append(...n) { this.children.push(...n); } }, extra);++ const chips = [];++ const row = mk({ hidden: true, replaceChildren(...n) { chips.length = 0; chips.push(...n); }, addEventListener(t, fn) { this.onclick = fn; },++ querySelectorAll: () => chips });++ const cards = cardTopics.map((t) => mk({ dataset: { topics: t } }));++ const grid = { querySelectorAll: () => cards };++ const document = {++ getElementById: (id) => (id === 'home-topics' ? row : id === 'stream-grid-live' ? grid : null),++ createElement: () => mk({ textContent: '' }),++ };++ return { document, row, chips, cards };++}++function load(dom) {++ const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');++ return new Function('document', '_capTag', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; } };`)(dom.document, _capTag);++}++++// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.++{++ const t = load(fakeDom([]))._streamTopics({ category: 'gaming', tags: '["Speedrun"]', ai_tags: '["multiframe","open-world","Gaming","overlay","live"]' });++ assert.deepStrictEqual(t, ['gaming', 'speedrun', 'open world'], 'meta words out, dashes read as spaces, no repeats');++ assert.deepStrictEqual(load(fakeDom([]))._streamTopics({ ai_tags: 'not json', tags: null }), [], 'a malformed list is no topics');++ assert.deepStrictEqual(load(fakeDom([]))._streamTopics({ ai_tags: ['cozy'] }), ['cozy'], 'an array is read as it is');++}++++const streams = [++ { category: 'gaming', ai_tags: '["fortnite"]' },++ { category: 'gaming', ai_tags: '["league"]' },++ { category: 'outdoors', ai_tags: '["hiking","stream"]' },++];++++// One stream live: no row, whatever it is tagged.++{++ const dom = fakeDom(['gaming|fortnite']);++ load(dom)._homeTopicsSync(streams.slice(0, 1));++ assert.strictEqual(dom.row.hidden, true, 'one stream is not something to filter');++}++++// Three live: All, then topics by how many streams have them, then A to Z.++{++ const dom = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);++ const m = load(dom);++ m._homeTopicsSync(streams);++ assert.strictEqual(dom.row.hidden, false);++ assert.deepStrictEqual(dom.chips.map((c) => c.dataset.topic), ['', 'gaming', 'fortnite', 'hiking', 'league', 'outdoors']);++ assert.strictEqual(dom.chips[0].attrs['aria-pressed'], 'true', 'All is pressed to begin with');++ assert.strictEqual(dom.chips[1].children[2].textContent, '2', 'Gaming counts two streams');++++ // Picking a topic keeps the cards that have it; picking it again shows them all.++ dom.row.onclick({ target: { closest: () => dom.chips[1] } });++ assert.strictEqual(m.topic, 'gaming');++ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, false, true]);++ assert.strictEqual(dom.chips[1].attrs['aria-pressed'], 'true');++ dom.row.onclick({ target: { closest: () => dom.chips[1] } });++ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, false, false], 'the same chip again clears it');++++ // The picked topic leaves the air: the filter lets go instead of showing an empty grid.++ dom.row.onclick({ target: { closest: () => dom.chips[4] } });++ assert.strictEqual(m.topic, 'league');++ m._homeTopicsSync([streams[0], streams[2]]);++ assert.strictEqual(m.topic, '', 'no stream has it any more');++}++++// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.++assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');++const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');++assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');++assert.match(css, /\.stream-grid > \.stream-card\[hidden\] \{ display: none; \}/, 'a filtered card really goes');++assert.match(css, /\.home-topics \{[^}]*overflow-x: auto/, 'one sideways row, never a fold');++// The live dot stays a dot: the shared SVG glyph fills its box, so the box has the glyph's own size.++const style = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');++assert.match(style, /\.section-header h2 > i\.live-dot \{\s*width: 1\.15em; height: 1\.15em;/, 'never width:auto');++console.log('home topics: all checks passed');
@@ -445,7 +445,35 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -445,7 +445,35 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@media (max-width: 560px) {
@media (max-width: 560px) {
.home-changelog { padding: 12px 13px 8px; }
.home-changelog { padding: 12px 13px 8px; }
}
}
-.stream-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
+.stream-card-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; min-width: 0; }
+/* Two of a live stream's topics beside its category, in quiet words; the metaright never wraps under them. */
+.stream-card-topics { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 0.72rem; }
+.stream-card-metaright { flex-shrink: 0; }
+.stream-grid > .stream-card[hidden] { display: none; }
+
+/* Topics live now: one sideways row of chips over the Live Now grid (rails, never a fold). */
+.home-topics {
+ display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
+ margin: -4px 0 14px; padding: 2px 2px 4px;
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
+ mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
+}
+.home-topics[hidden] { display: none; }
+.home-topics::-webkit-scrollbar { display: none; }
+.home-topic {
+ flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px;
+ min-height: 34px; padding: 0 13px;
+ border: 1px solid var(--border); border-radius: 999px;
+ background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent);
+ color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; white-space: nowrap; cursor: pointer;
+ transition: background var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease), border-color var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease), color var(--ov-dur-1, 0.12s) var(--ov-ease-out, ease);
+}
+.home-topic:hover { border-color: var(--accent); color: var(--text-primary); }
+.home-topic:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
+.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }
+@media (pointer: coarse) { .home-topic { min-height: 44px; } }
.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }
.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
.stream-card-appear { animation: streamCardAppear var(--ov-dur-4, 0.6s) var(--ov-ease-out, ease); }
.stream-card-appear { animation: streamCardAppear var(--ov-dur-4, 0.6s) var(--ov-ease-out, ease); }
@@ -9658,9 +9658,10 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -9658,9 +9658,10 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
border: 1px solid color-mix(in srgb, var(--sc-on) 34%, transparent);
border: 1px solid color-mix(in srgb, var(--sc-on) 34%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--sc-on) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);
box-shadow: 0 0 16px color-mix(in srgb, var(--sc-on) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);
}
}
-/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. */
+/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. Its size is the glyph's
+ own 1.15em: "auto" let the shared SVG glyph (ov-glyphs) fill the heading, a 300 px red disc on a desktop. */
.section-header h2 > i.live-dot {
.section-header h2 > i.live-dot {
- width: auto; height: auto; background: none; border: 0; box-shadow: none;
+ width: 1.15em; height: 1.15em; background: none; border: 0; box-shadow: none;
color: var(--live-red-text, var(--live-red)); font-size: 0.62rem;
color: var(--live-red-text, var(--live-red)); font-size: 0.62rem;
}
}
/* A rule that carries the section colour out across the page and fades. */
/* A rule that carries the section colour out across the page and fades. */
@@ -80,6 +80,9 @@
@@ -80,6 +80,9 @@
<span id="live-count" class="badge">0</span>
<span id="live-count" class="badge">0</span>
<button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button>
<button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button>
</div>
</div>
+ <!-- Topics live now (app-home.js _homeTopicsSync): the live streams' categories and tags as filters over the
+ grid below; filled and shown only when two streams or more are live with two topics or more. -->
+ <div class="home-topics" id="home-topics" role="group" aria-label="Filter live streams by topic" hidden></div>
<div class="stream-grid" id="stream-grid-live">
<div class="stream-grid" id="stream-grid-live">
<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>
@@ -1395,6 +1395,7 @@ async function loadHome() {
@@ -1395,6 +1395,7 @@ 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);
+ _homeTopicsSync(streams);
_homeFeaturedSync(streams.length > 0);
_homeFeaturedSync(streams.length > 0);
if (window.homeFeatured && homeFeatured.markGrid) homeFeatured.markGrid();
if (window.homeFeatured && homeFeatured.markGrid) homeFeatured.markGrid();
}, { ttl: 20000 });
}, { ttl: 20000 });
@@ -1759,6 +1760,82 @@ function _thumbTint(s) {
@@ -1759,6 +1760,82 @@ function _thumbTint(s) {
return `hsl(${h} 45% 34%)`;
return `hsl(${h} 45% 34%)`;
}
}
+/* ── Topics: what the live streams are about, as filters over the Live Now grid ─────────────
+ A stream's topics are its category, the tags its streamer set and the tags the AI read from the
+ stream (streams.ai_tags), minus words that say nothing to a viewer here: every stream is live,
+ streamed and on a camera or a screen. Live market outcome 2: a viewer picks a topic, the grid
+ keeps the streams that have it; nothing else on the page changes. */
+const _TOPIC_STOP = new Set(['live', 'live stream', 'livestream', 'stream', 'streaming', 'streamer', 'chat', 'livechat',
+ 'live chat', 'overlay', 'ui', 'setup', 'lobby', 'facecam', 'multicam', 'multiframe', 'webcam', 'camera', 'screen',
+ 'desktop', 'browser', 'obs', 'gameplay', 'commentary', 'surveillance', 'stream overlay', 'pc']);
+function _streamTopics(s) {
+ const out = [];
+ const add = (t) => {
+ const v = String(t || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
+ if (v && v.length <= 32 && !_TOPIC_STOP.has(v) && !out.includes(v)) out.push(v);
+ };
+ add(s && s.category);
+ for (const field of [s && s.tags, s && s.ai_tags]) {
+ let list = field;
+ if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = null; } }
+ if (Array.isArray(list)) list.forEach(add);
+ }
+ return out;
+}
+
+let _homeTopic = '';
+/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more. */
+function _homeTopicsSync(streams) {
+ const row = document.getElementById('home-topics');
+ if (!row) return;
+ const counts = new Map();
+ for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);
+ const show = streams.length >= 2 && counts.size >= 2;
+ row.hidden = !show;
+ if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }
+ if (_homeTopic && !counts.has(_homeTopic)) _homeTopic = '';
+ // Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.
+ const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
+ const key = `${streams.length}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;
+ if (row.dataset.key !== key) {
+ row.dataset.key = key;
+ const chip = (topic, label, n) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'home-topic';
+ b.dataset.topic = topic;
+ b.append(label);
+ const c = document.createElement('span');
+ c.className = 'home-topic-n';
+ c.textContent = String(n);
+ b.append(' ', c);
+ return b;
+ };
+ const nodes = [chip('', 'All', streams.length)];
+ for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));
+ row.replaceChildren(...nodes);
+ if (!row._ovBound) {
+ row._ovBound = true;
+ row.addEventListener('click', (e) => {
+ const b = e.target.closest('.home-topic');
+ if (!b) return;
+ _homeTopic = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;
+ _homeTopicsApply();
+ });
+ }
+ }
+ _homeTopicsApply();
+}
+function _homeTopicsApply() {
+ const row = document.getElementById('home-topics');
+ if (row) row.querySelectorAll('.home-topic').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.topic === _homeTopic)));
+ const grid = document.getElementById('stream-grid-live');
+ if (!grid) return;
+ grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {
+ card.hidden = !!_homeTopic && !(card.dataset.topics || '').split('|').includes(_homeTopic);
+ });
+}
+
function streamCardHTML(s, isLive) {
function streamCardHTML(s, isLive) {
let navUrl;
let navUrl;
if (isLive && s.id) {
if (isLive && s.id) {
@@ -1772,8 +1849,11 @@ function streamCardHTML(s, isLive) {
@@ -1772,8 +1849,11 @@ function streamCardHTML(s, isLive) {
const thumb = (!isLive && s.vod_thumbnail_url) ? s.vod_thumbnail_url : s.thumbnail_url;
const thumb = (!isLive && s.vod_thumbnail_url) ? s.vod_thumbnail_url : s.thumbnail_url;
const duration = !isLive && s.vod_duration ? `<span class="stream-card-duration">${formatDuration(s.vod_duration)}</span>` : '';
const duration = !isLive && s.vod_duration ? `<span class="stream-card-duration">${formatDuration(s.vod_duration)}</span>` : '';
const endedAgo = !isLive && s.ended_at ? `<span class="stream-card-ago">${timeAgo(s.ended_at)}</span>` : '';
const endedAgo = !isLive && s.ended_at ? `<span class="stream-card-ago">${timeAgo(s.ended_at)}</span>` : '';
+ const topics = isLive ? _streamTopics(s) : [];
+ const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
+ const shown = topics.filter((t) => t !== cat).slice(0, 2);
return `
return `
- <a class="stream-card" data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
+ <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
@@ -1791,6 +1871,7 @@ function streamCardHTML(s, isLive) {
@@ -1791,6 +1871,7 @@ function streamCardHTML(s, isLive) {
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
<div class="stream-card-meta">
<div class="stream-card-meta">
${s.category ? `<span class="stream-card-tag"${_catInferred(s) ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${esc(_capTag(s.category))}${_catInferred(s) ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
${s.category ? `<span class="stream-card-tag"${_catInferred(s) ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${esc(_capTag(s.category))}${_catInferred(s) ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
+ ${shown.length ? `<span class="stream-card-topics">${shown.map((t) => esc(_capTag(t))).join(' · ')}</span>` : ''}
<span class="stream-card-metaright">
<span class="stream-card-metaright">
${isLive && s.started_at ? `<span class="stream-card-uptime" data-since="${esc(s.started_at)}"><i class="fa-solid fa-clock"></i> ${formatUptime(s.started_at)}</span>` : ''}
${isLive && s.started_at ? `<span class="stream-card-uptime" data-since="${esc(s.started_at)}"><i class="fa-solid fa-clock"></i> ${formatUptime(s.started_at)}</span>` : ''}
${isLive ? `<span class="stream-card-vcount"><i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}</span>` : ''}
${isLive ? `<span class="stream-card-vcount"><i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}</span>` : ''}
@@ -1856,6 +1937,8 @@ function _crossfadeThumb(imgEl, newSrc) {
@@ -1856,6 +1937,8 @@ function _crossfadeThumb(imgEl, newSrc) {
}
}
function _updateLiveCard(card, s) {
function _updateLiveCard(card, s) {
+ // The AI re-reads a stream as it goes, so its topics can change between refreshes.
+ card.dataset.topics = _streamTopics(s).join('|');
const vc = card.querySelector('.stream-card-vcount');
const vc = card.querySelector('.stream-card-vcount');
if (vc) vc.innerHTML = `<i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}`;
if (vc) vc.innerHTML = `<i class="fa-solid fa-eye"></i> ${s.total_viewer_count || s.viewer_count || 0}`;
const up = card.querySelector('.stream-card-uptime');
const up = card.querySelector('.stream-card-uptime');
@@ -1995,6 +2078,7 @@ async function refreshHomeLive() {
@@ -1995,6 +2078,7 @@ async function refreshHomeLive() {
setTimeout(() => { try { el.remove(); } catch {} }, 420);
setTimeout(() => { try { el.remove(); } catch {} }, 420);
}
}
});
});
+ _homeTopicsSync(streams);
}
}
// Refresh the paginated home sections (only at page 1, only when visible — so we
// Refresh the paginated home sections (only at page 1, only when visible — so we
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// Topics live now (Live market outcome 2): the live streams' categories and tags, the AI's included, as one row of
+// filters over the Live Now grid. The functions are public/js/app-home.js's own, run here against a small fake DOM.
+// node test/home-topics.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
+const start = src.indexOf('const _TOPIC_STOP');
+const end = src.indexOf('function streamCardHTML(');
+assert.ok(start > 0 && end > start, 'app-home.js has the topics block before the card');
+const block = src.slice(start, end);
+
+// A fake document: the row and the grid's cards, enough for _homeTopicsSync and _homeTopicsApply.
+function fakeDom(cardTopics) {
+ const mk = (extra) => Object.assign({ hidden: false, dataset: {}, attrs: {}, children: [],
+ setAttribute(k, v) { this.attrs[k] = v; }, append(...n) { this.children.push(...n); } }, extra);
+ const chips = [];
+ const row = mk({ hidden: true, replaceChildren(...n) { chips.length = 0; chips.push(...n); }, addEventListener(t, fn) { this.onclick = fn; },
+ querySelectorAll: () => chips });
+ const cards = cardTopics.map((t) => mk({ dataset: { topics: t } }));
+ const grid = { querySelectorAll: () => cards };
+ const document = {
+ getElementById: (id) => (id === 'home-topics' ? row : id === 'stream-grid-live' ? grid : null),
+ createElement: () => mk({ textContent: '' }),
+ };
+ return { document, row, chips, cards };
+}
+function load(dom) {
+ const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
+ return new Function('document', '_capTag', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; } };`)(dom.document, _capTag);
+}
+
+// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.
+{
+ const t = load(fakeDom([]))._streamTopics({ category: 'gaming', tags: '["Speedrun"]', ai_tags: '["multiframe","open-world","Gaming","overlay","live"]' });
+ assert.deepStrictEqual(t, ['gaming', 'speedrun', 'open world'], 'meta words out, dashes read as spaces, no repeats');
+ assert.deepStrictEqual(load(fakeDom([]))._streamTopics({ ai_tags: 'not json', tags: null }), [], 'a malformed list is no topics');
+ assert.deepStrictEqual(load(fakeDom([]))._streamTopics({ ai_tags: ['cozy'] }), ['cozy'], 'an array is read as it is');
+}
+
+const streams = [
+ { category: 'gaming', ai_tags: '["fortnite"]' },
+ { category: 'gaming', ai_tags: '["league"]' },
+ { category: 'outdoors', ai_tags: '["hiking","stream"]' },
+];
+
+// One stream live: no row, whatever it is tagged.
+{
+ const dom = fakeDom(['gaming|fortnite']);
+ load(dom)._homeTopicsSync(streams.slice(0, 1));
+ assert.strictEqual(dom.row.hidden, true, 'one stream is not something to filter');
+}
+
+// Three live: All, then topics by how many streams have them, then A to Z.
+{
+ const dom = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);
+ const m = load(dom);
+ m._homeTopicsSync(streams);
+ assert.strictEqual(dom.row.hidden, false);
+ assert.deepStrictEqual(dom.chips.map((c) => c.dataset.topic), ['', 'gaming', 'fortnite', 'hiking', 'league', 'outdoors']);
+ assert.strictEqual(dom.chips[0].attrs['aria-pressed'], 'true', 'All is pressed to begin with');
+ assert.strictEqual(dom.chips[1].children[2].textContent, '2', 'Gaming counts two streams');
+
+ // Picking a topic keeps the cards that have it; picking it again shows them all.
+ dom.row.onclick({ target: { closest: () => dom.chips[1] } });
+ assert.strictEqual(m.topic, 'gaming');
+ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, false, true]);
+ assert.strictEqual(dom.chips[1].attrs['aria-pressed'], 'true');
+ dom.row.onclick({ target: { closest: () => dom.chips[1] } });
+ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, false, false], 'the same chip again clears it');
+
+ // The picked topic leaves the air: the filter lets go instead of showing an empty grid.
+ dom.row.onclick({ target: { closest: () => dom.chips[4] } });
+ assert.strictEqual(m.topic, 'league');
+ m._homeTopicsSync([streams[0], streams[2]]);
+ assert.strictEqual(m.topic, '', 'no stream has it any more');
+}
+
+// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.
+assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');
+const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');
+assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');
+assert.match(css, /\.stream-grid > \.stream-card\[hidden\] \{ display: none; \}/, 'a filtered card really goes');
+assert.match(css, /\.home-topics \{[^}]*overflow-x: auto/, 'one sideways row, never a fold');
+// The live dot stays a dot: the shared SVG glyph fills its box, so the box has the glyph's own size.
+const style = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
+assert.match(style, /\.section-header h2 > i\.live-dot \{\s*width: 1\.15em; height: 1\.15em;/, 'never width:auto');
+console.log('home topics: all checks passed');