openvibers/openvibe.live Commits

Home cards say when the AI inferred a category (roadmap 33.4): live stream cards, featured cards and the Star card show 'Gaming · inferred' with a tooltip when the category is the stream's or channel's ai_category, as the channel and VOD/clip pages already do; /api/home star carries category_inferred

OP OpenVibers committed 24 Sept 2026 · 03e3814837 · 5 files, +9 −3


Changes

ddiff --git a/public/css/features/featured.css b/public/css/features/featured.css
iindex bd8caf15..15b5266e 100644
---- a/public/css/features/featured.css
++++ b/public/css/features/featured.css
@@@ -44,6 +44,7 @@
.hf-who:hover { color: var(--text-primary); }
.hf-who b { color: var(--text-primary); }
.hf-who .muted { margin-left: auto; font-size: 0.74rem; white-space: nowrap; }
++.hf-tag .cat-inferred { opacity: .78; }
.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
.hf-now {
position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex adb5a650..1f574005 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -854,6 +854,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-name a:hover { color: var(--accent-light); }
.star-name .star-jp { display: block; font-size: 0.55em; font-weight: 600; color: var(--text-secondary); margin-top: 2px; }
.star-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 10px; }
++.stream-card-tag .cat-inferred, .star-chip .cat-inferred { font-weight: 500; opacity: .78; }
.star-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px;
font-size: 0.74rem; font-weight: 700; color: var(--text-primary);
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex f9fe887f..37ec23af 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -24,6 +24,9 @@
const _SWR_PREFIX = 'ovswr:';
// Bump when a cached response's shape changes; older records are ignored instead of mis-rendered.
const _SWR_SCHEMA = 2;
++/** A category the AI inferred (the server's category_inferred, or a stream row whose effective category is its ai_category) says so (roadmap 33.4). */
++function _catInferred(s) { return s && s.category_inferred != null ? !!s.category_inferred : !!(s && s.ai_category && s.ai_category === s.category); }
++
function _swrKey(path) {
let uid = 0;
try { uid = (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || 0; } catch { /* */ }
@@@ -1691,7 +1694,7 @@ function streamCardHTML(s, isLive) {
${(isLive && s.description) ? `<div class="stream-card-desc" title="Click to expand" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('expanded')">${esc(s.description)}</div>` : ''}
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
<div class="stream-card-meta">
-- ${s.category ? `<span class="stream-card-tag">${esc(_capTag(s.category))}</span>` : ''}
++ ${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>` : ''}
<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>` : ''}
@@@ -2115,7 +2118,7 @@ function _renderHomeStar(data) {
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
foreign ? `<span class="star-chip"><i class="fa-solid fa-language"></i> Chat auto-translated both ways</span>` : '',
-- s.category ? `<span class="star-chip">${esc(_capTag(s.category))}</span>` : '',
++ s.category ? `<span class="star-chip"${_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>` : '',
(s.follower_count > 0) ? `<span class="star-chip gold"><i class="fa-solid fa-heart"></i> ${esc(String(s.follower_count))} follower${s.follower_count === 1 ? '' : 's'}</span>` : '',
].filter(Boolean).join('');
const isLive = !!s.live;
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
iindex 8cbfba15..c8e36bce 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -135,7 +135,7 @@
<a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
<b>${escText(s.display_name || s.username)}</b>
-- ${s.category ? `<span class="hf-tag">${escText(s.category)}</span>` : ''}
++ ${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe&#39;s AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
</a>
${m && m.description ? `<div class="hf-now"><div class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></div>${escText(m.description)}</div>` : ''}
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex b9badf50..b4b9f619 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -361,6 +361,7 @@ router.get('/star', async (req, res) => {
avatar_url: user.avatar_url || null, profile_color: user.profile_color || null,
bio, bio_en, language: { code, name: i18n.langName(code), flag: i18n.langFlag(code) },
ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
++ category_inferred: !!channel.ai_category,
follower_count, live: liveSafe,
last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
pick: (() => {