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
Changes
@@@ -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);@@@ -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);@@@ -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'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'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;@@@ -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'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>` : ''}@@@ -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: (() => {
@@ -44,6 +44,7 @@
@@ -44,6 +44,7 @@
.hf-who:hover { color: var(--text-primary); }
.hf-who:hover { color: var(--text-primary); }
.hf-who b { 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-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-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 {
.hf-now {
position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
@@ -854,6 +854,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -854,6 +854,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-name a:hover { color: var(--accent-light); }
.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-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; }
.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 {
.star-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px;
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);
font-size: 0.74rem; font-weight: 700; color: var(--text-primary);
@@ -24,6 +24,9 @@
@@ -24,6 +24,9 @@
const _SWR_PREFIX = 'ovswr:';
const _SWR_PREFIX = 'ovswr:';
// Bump when a cached response's shape changes; older records are ignored instead of mis-rendered.
// Bump when a cached response's shape changes; older records are ignored instead of mis-rendered.
const _SWR_SCHEMA = 2;
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) {
function _swrKey(path) {
let uid = 0;
let uid = 0;
try { uid = (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || 0; } catch { /* */ }
try { uid = (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || 0; } catch { /* */ }
@@ -1691,7 +1694,7 @@ function streamCardHTML(s, isLive) {
@@ -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>` : ''}
${(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)}
${_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">${esc(_capTag(s.category))}</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>` : ''}
<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>` : ''}
@@ -2115,7 +2118,7 @@ function _renderHomeStar(data) {
@@ -2115,7 +2118,7 @@ function _renderHomeStar(data) {
const chips = [
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
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>` : '',
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'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>` : '',
(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('');
].filter(Boolean).join('');
const isLive = !!s.live;
const isLive = !!s.live;
@@ -135,7 +135,7 @@
@@ -135,7 +135,7 @@
<a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
<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) : ''}
${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
<b>${escText(s.display_name || s.username)}</b>
<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'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>` : ''}
${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
</a>
</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>` : ''}
${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>` : ''}
@@ -361,6 +361,7 @@ router.get('/star', async (req, res) => {
@@ -361,6 +361,7 @@ router.get('/star', async (req, res) => {
avatar_url: user.avatar_url || null, profile_color: user.profile_color || null,
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) },
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,
ai_overview: channel.ai_overview || null, category: channel.ai_category || channel.category || null,
+ category_inferred: !!channel.ai_category,
follower_count, live: liveSafe,
follower_count, live: liveSafe,
last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
last_live_at: live ? null : (last_stream && (last_stream.ended_at || last_stream.started_at)) || null,
pick: (() => {
pick: (() => {