openvibers/openvibe.live Commits

Topics live now: filter the live grid by what streams are about (#520)

OP OpenVibers committed 12 days ago · a3dc24ea46 · 5 files, +211 −4

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 2962e98a..ba2471cc 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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); }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex c9375956..6e31c195 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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. */
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 08084ab6..082a8b24 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 3b50292f..981d9183 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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&#39;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 we
ddiff --git a/test/home-topics.test.js b/test/home-topics.test.js
nnew file mode 100644
iindex 00000000..5f6ec229
---- /dev/null
++++ b/test/home-topics.test.js
@@@ -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');