openvibers/openvibe.live Commits
Channel page on phones, polished: over a custom offline image the 'Most watched' card no longer gets clipped (below 720px of column the image becomes a banner and the card follows it); the discover board shares the column's gutter; About leads with the streamer's own words as lead text (no box), the AI summary follows as a quiet note, and every channel ends with 'Latest videos' and 'Popular clips' rows (snap-scrolling cards, 'See all' opens the tab) instead of a screen of empty space; the page no longer forces a full-viewport height above the footer; the tab row fades at its right edge while more tabs are hidden past it.
Changes
@@@ -887,7 +887,7 @@cursor: pointer; transition: color 0.15s ease, background 0.15s ease;}.ch-tab-edit:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 26%, transparent); }--.ch-about-content { display: flex; flex-direction: column; gap: 14px; }++.ch-about-content { display: flex; flex-direction: column; gap: 24px; }.ch-about-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }.ch-about-toolbar-left, .ch-about-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }.ch-about-drag-hint { font-size: 0.8rem; }@@@ -895,10 +895,8 @@.ch-about-mods-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; margin-right: 4px; }.ch-about-mods-toggle input { accent-color: var(--accent); }.ch-about-save-btn.is-visible { opacity: 1; transform: none; pointer-events: auto; }--.ch-about-bio {-- background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px;-- padding: 16px 18px; line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap; word-break: break-word;--}++/* The streamer's own words lead About: lead text on the page, no box around it. */++.ch-about-bio { font-size: 1.02rem; line-height: 1.62; color: var(--text-primary); white-space: pre-wrap; word-break: break-word; max-width: 72ch; }.ch-about-panels { display: grid; grid-template-columns: repeat(12, 1fr); gap: 14px; align-items: start; }@media (max-width: 560px) { .ch-about-panels { grid-template-columns: 1fr; } }.ch-about-panel {@@@ -1260,11 +1258,7 @@.ai-tl-ts:hover { text-decoration: underline; }.ai-tl-tr-text { color: var(--text-secondary); }.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }--.about-ai-overview { margin: 0 0 16px; }--/* In About the overview is part of the page, not a card: no fill, no frame, a thin accent rule beside it. */--.ai-tl-overview-card.about-ai-overview { background: transparent; border: none; border-radius: 0; padding: 2px 0 2px 14px;-- border-left: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 55%, transparent); }--.ai-tl-overview-card.about-ai-overview .ai-tl-overview-label { margin-bottom: 5px; }++/* Wide screens: the tabs sit in the main column (app-channel.js _placeBelowFold) and use its full width. */.channel-below-fold.is-in-main { padding: 4px 0 28px; }@@@ -1513,3 +1507,55 @@ body.controls-docked .messenger-toggle { z-index: 1100; }#page-channel .chat-sidebar .chat-header { padding-top: 18px; }}@media (hover: none) { .chat-sidebar > .chat-grip::after { opacity: .75; } }++++/* ── About, one visual language (2026-09-28) ─────────────────────++ Sections share a small uppercase heading with an accent icon and the same 24px rhythm; nothing is boxed but the++ things you tap (panels, link cards, video cards). */++.ch-about-sec { min-width: 0; }++.ch-about-h { display: flex; align-items: center; gap: 7px; margin: 0 0 10px; font-size: 0.74rem; font-weight: 750; letter-spacing: 0.08em;++ text-transform: uppercase; color: var(--text-muted); }++.ch-about-h > i { color: var(--accent); font-size: 0.9em; }++.ch-about-h-note { font-weight: 500; letter-spacing: 0; text-transform: none; opacity: 0.8; }++.ch-about-more { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 4px 2px; border: 0; background: none; cursor: pointer;++ color: var(--accent-light, var(--accent)); font: 600 0.78rem/1 inherit; letter-spacing: 0; text-transform: none; }++.ch-about-more:hover { color: var(--text-primary); }++.ch-about-more i { font-size: 0.72rem; transition: transform .18s ease; }++.ch-about-more:hover i { transform: translateX(2px); }++.about-ai-overview { color: var(--text-secondary); font-size: 0.95rem; line-height: 1.6; max-width: 72ch; }++/* Latest videos / Popular clips: a snap-scrolling row of cards. */++.ch-about-row { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(196px, 236px); gap: 14px; overflow-x: auto;++ scroll-snap-type: x proximity; padding: 2px 2px 8px; margin: 0 -2px; scrollbar-width: thin;++ scrollbar-color: color-mix(in srgb, var(--accent) 30%, transparent) transparent; overscroll-behavior-x: contain; }++.ch-about-card { scroll-snap-align: start; display: flex; flex-direction: column; gap: 6px; min-width: 0; color: inherit; text-decoration: none; }++.ch-about-card-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden;++ background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform .2s cubic-bezier(.22,1,.36,1), box-shadow .2s, border-color .2s; }++.ch-about-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }++.ch-about-card:hover .ch-about-card-thumb, .ch-about-card:focus-visible .ch-about-card-thumb { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 55%, var(--border));++ box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 16%, rgba(0,0,0,.35)); }++.ch-about-card-dur { position: absolute; right: 6px; bottom: 6px; padding: 2px 6px; border-radius: 6px; font-size: 0.68rem; font-weight: 700; color: #fff; background: rgba(0,0,0,.72); }++.ch-about-card-title { font-size: 0.88rem; font-weight: 650; line-height: 1.3; color: var(--text-primary);++ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.ch-about-card-meta { font-size: 0.75rem; color: var(--text-muted); }++@media (prefers-reduced-motion: reduce) { .ch-about-card-thumb, .ch-about-more i { transition: none; } }++++/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer++ whenever the open tab was short. */++#page-channel.page { min-height: 0; }++++/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */++.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }++++/* A custom offline image with the "most watched" card over it: when the column is narrow the card no longer fits over++ the image (it was clipped), so the image becomes a banner and the card follows it in the page. */++.ch-offline-area .stream-main { container: chmain / inline-size; }++@container chmain (max-width: 720px) {++ .ch-offline-screen:has(> .ch-offline-overlay) { aspect-ratio: auto; max-height: none; display: block; overflow: visible; background: transparent; border: 0; border-radius: 0; }++ .ch-offline-screen:has(> .ch-offline-overlay) > .ch-offline-media { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; border: 1px solid var(--border); }++ .ch-offline-screen > .ch-offline-overlay { position: static; inset: auto; display: flex !important; background: none; padding: 16px 0 0; pointer-events: auto; }++ .ch-offline-overlay-close, .ch-offline-reopen { display: none !important; }++ .ch-offline-overlay .off-cyc { padding: 0; }++ .ch-offline-overlay .off-cyc-title { color: var(--text-primary); }++}++++/* Tabs that run past the edge fade out there, so it reads as "scroll for more" (class set by app-channel.js). */++.ch-tabs.is-clipped { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); }@@@ -582,8 +582,20 @@ function _restoreChannelChat() {toggleChannelChat(hidden);}++/** Fade the tab row's right edge while more tabs are hidden past it. */++function _watchTabsClip() {++ const t = document.getElementById('ch-tabs');++ if (!t || t._ovClipWatch) return;++ t._ovClipWatch = true;++ const upd = () => t.classList.toggle('is-clipped', t.scrollWidth - t.clientWidth - t.scrollLeft > 4);++ t.addEventListener('scroll', upd, { passive: true });++ if ('ResizeObserver' in window) new ResizeObserver(upd).observe(t); else window.addEventListener('resize', upd);++ upd();++}++async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {_restoreChannelChat();++ _watchTabsClip();try {const isNewChannel = currentChannelUsername !== username;currentChannelUsername = username;@@@ -623,6 +635,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIconst vods = data.vods || [];const clips = data.clips || [];const clipsOfStreams = data.clipsOfStreams || [];++ // About's "Latest videos" / "Popular clips" rows reuse what this load already fetched.++ _aboutRecent = { username, vods: vods.slice(0, 8), clips: clipsOfStreams.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0)).slice(0, 8) };++ _renderAboutRecent();const managedStreams = data.managed_streams || [];const liveStreams = streams.filter(s => s && s.is_live);const rsRestream = data.rs_restream || {};@@@ -2283,20 +2298,47 @@ function _aboutAiEffectiveShow() {if (_aboutAiPref === 'hide') return false;return !(_aboutBio || (_aboutPanels && _aboutPanels.length)); // auto → only when no bio/panels}--// The AI overview card shown at the top of the About tab (view mode).++// The AI overview in the About tab (view mode): a quiet note under the streamer's own words, not a card.function _aboutAiOverviewHTML() {if (!_aboutAiEffectiveShow()) return '';-- return `<div class="ai-tl-overview-card about-ai-overview">-- <div class="ai-tl-overview-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Overview</div>++ return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">++ <h3 class="ch-about-h"><i class="fa-solid fa-wand-magic-sparkles"></i> AI summary <span class="ch-about-h-note">from past streams</span></h3>${_collapsibleOverview(_aboutAiOverview)}-- </div>`;++ </section>`;++}++++// "Latest videos" and "Popular clips" rows at the end of About: something to watch on every channel, and no screen of++// empty space under a one-line bio. Filled from the channel load (_aboutRecent), re-rendered with About.++let _aboutRecent = { username: null, vods: [], clips: [] };++function _aboutRowCard(kind, it) {++ const isVod = kind === 'vod';++ const href = isVod ? `/vod/${it.id}` : `/clip/${it.id}`;++ const gen = isVod ? `/api/thumbnails/generate/vod/${it.id}` : `/api/thumbnails/generate/clip/${it.id}`;++ const views = Number(it.view_count || 0);++ return `<a class="ch-about-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">++ <span class="ch-about-card-thumb">${thumbImg(it.thumbnail_url, isVod ? 'fa-video' : 'fa-scissors', it.title, gen)}${it.duration_seconds ? `<span class="ch-about-card-dur">${formatDuration(it.duration_seconds)}</span>` : ''}</span>++ <span class="ch-about-card-title">${esc(it.title || (isVod ? 'Stream' : 'Clip'))}</span>++ <span class="ch-about-card-meta">${views.toLocaleString()} view${views === 1 ? '' : 's'} · ${timeAgo(it.created_at)}</span>++ </a>`;++}++function _renderAboutRecent() {++ const host = document.getElementById('ch-about-recent');++ if (!host) return;++ const r = _aboutRecent;++ if (!r || r.username !== currentChannelUsername) { host.innerHTML = ''; return; }++ const row = (title, icon, tab, kind, items) => items.length ? `<section class="ch-about-sec">++ <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'))">See all <i class="fa-solid fa-arrow-right"></i></button></h3>++ <div class="ch-about-row">${items.map((it) => _aboutRowCard(kind, it)).join('')}</div>++ </section>` : '';++ host.innerHTML = row('Latest videos', 'fa-video', 'videos', 'vod', r.vods) + row('Popular clips', 'fa-scissors', 'clips', 'clip', r.clips);}function _renderAboutView() {const host = document.getElementById('ch-about-content');if (!host) return;const aiHtml = _aboutAiOverviewHTML();-- const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length;++ const hasRecent = _aboutRecent.username === currentChannelUsername && (_aboutRecent.vods.length || _aboutRecent.clips.length);++ const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length || hasRecent;let html = '';if (!hasContent) {html += _aboutCanEdit@@@ -2305,11 +2347,14 @@ function _renderAboutView() {host.innerHTML = html;return;}-- html += aiHtml; // AI overview card leads the About tab++ // The streamer's own words lead; the AI summary follows as a note; then links, panels, and something to watch.if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;++ html += aiHtml;html += '<section class="ch-socials" id="ch-about-socials" hidden></section>';-- html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';++ if (_aboutPanels.length) html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';++ html += '<div id="ch-about-recent"></div>';host.innerHTML = html;++ _renderAboutRecent();if (window.ChannelSocials) ChannelSocials.cards(document.getElementById('ch-about-socials'), _aboutSocials, currentChannelUsername);_fillWeatherPanels();void _fillBioTranslation();
@@ -887,7 +887,7 @@
@@ -887,7 +887,7 @@
cursor: pointer; transition: color 0.15s ease, background 0.15s ease;
cursor: pointer; transition: color 0.15s ease, background 0.15s ease;
}
}
.ch-tab-edit:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 26%, transparent); }
.ch-tab-edit:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 26%, transparent); }
-.ch-about-content { display: flex; flex-direction: column; gap: 14px; }
+.ch-about-content { display: flex; flex-direction: column; gap: 24px; }
.ch-about-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.ch-about-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.ch-about-toolbar-left, .ch-about-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ch-about-toolbar-left, .ch-about-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ch-about-drag-hint { font-size: 0.8rem; }
.ch-about-drag-hint { font-size: 0.8rem; }
@@ -895,10 +895,8 @@
@@ -895,10 +895,8 @@
.ch-about-mods-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; margin-right: 4px; }
.ch-about-mods-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; margin-right: 4px; }
.ch-about-mods-toggle input { accent-color: var(--accent); }
.ch-about-mods-toggle input { accent-color: var(--accent); }
.ch-about-save-btn.is-visible { opacity: 1; transform: none; pointer-events: auto; }
.ch-about-save-btn.is-visible { opacity: 1; transform: none; pointer-events: auto; }
-.ch-about-bio {
- background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px;
- padding: 16px 18px; line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap; word-break: break-word;
-}
+/* The streamer's own words lead About: lead text on the page, no box around it. */
+.ch-about-bio { font-size: 1.02rem; line-height: 1.62; color: var(--text-primary); white-space: pre-wrap; word-break: break-word; max-width: 72ch; }
.ch-about-panels { display: grid; grid-template-columns: repeat(12, 1fr); gap: 14px; align-items: start; }
.ch-about-panels { display: grid; grid-template-columns: repeat(12, 1fr); gap: 14px; align-items: start; }
@media (max-width: 560px) { .ch-about-panels { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .ch-about-panels { grid-template-columns: 1fr; } }
.ch-about-panel {
.ch-about-panel {
@@ -1260,11 +1258,7 @@
@@ -1260,11 +1258,7 @@
.ai-tl-ts:hover { text-decoration: underline; }
.ai-tl-ts:hover { text-decoration: underline; }
.ai-tl-tr-text { color: var(--text-secondary); }
.ai-tl-tr-text { color: var(--text-secondary); }
.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }
.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }
-.about-ai-overview { margin: 0 0 16px; }
-/* In About the overview is part of the page, not a card: no fill, no frame, a thin accent rule beside it. */
-.ai-tl-overview-card.about-ai-overview { background: transparent; border: none; border-radius: 0; padding: 2px 0 2px 14px;
- border-left: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 55%, transparent); }
-.ai-tl-overview-card.about-ai-overview .ai-tl-overview-label { margin-bottom: 5px; }
+
/* Wide screens: the tabs sit in the main column (app-channel.js _placeBelowFold) and use its full width. */
/* Wide screens: the tabs sit in the main column (app-channel.js _placeBelowFold) and use its full width. */
.channel-below-fold.is-in-main { padding: 4px 0 28px; }
.channel-below-fold.is-in-main { padding: 4px 0 28px; }
@@ -1513,3 +1507,55 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1513,3 +1507,55 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
#page-channel .chat-sidebar .chat-header { padding-top: 18px; }
#page-channel .chat-sidebar .chat-header { padding-top: 18px; }
}
}
@media (hover: none) { .chat-sidebar > .chat-grip::after { opacity: .75; } }
@media (hover: none) { .chat-sidebar > .chat-grip::after { opacity: .75; } }
+
+/* ── About, one visual language (2026-09-28) ─────────────────────
+ Sections share a small uppercase heading with an accent icon and the same 24px rhythm; nothing is boxed but the
+ things you tap (panels, link cards, video cards). */
+.ch-about-sec { min-width: 0; }
+.ch-about-h { display: flex; align-items: center; gap: 7px; margin: 0 0 10px; font-size: 0.74rem; font-weight: 750; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--text-muted); }
+.ch-about-h > i { color: var(--accent); font-size: 0.9em; }
+.ch-about-h-note { font-weight: 500; letter-spacing: 0; text-transform: none; opacity: 0.8; }
+.ch-about-more { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 4px 2px; border: 0; background: none; cursor: pointer;
+ color: var(--accent-light, var(--accent)); font: 600 0.78rem/1 inherit; letter-spacing: 0; text-transform: none; }
+.ch-about-more:hover { color: var(--text-primary); }
+.ch-about-more i { font-size: 0.72rem; transition: transform .18s ease; }
+.ch-about-more:hover i { transform: translateX(2px); }
+.about-ai-overview { color: var(--text-secondary); font-size: 0.95rem; line-height: 1.6; max-width: 72ch; }
+/* Latest videos / Popular clips: a snap-scrolling row of cards. */
+.ch-about-row { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(196px, 236px); gap: 14px; overflow-x: auto;
+ scroll-snap-type: x proximity; padding: 2px 2px 8px; margin: 0 -2px; scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--accent) 30%, transparent) transparent; overscroll-behavior-x: contain; }
+.ch-about-card { scroll-snap-align: start; display: flex; flex-direction: column; gap: 6px; min-width: 0; color: inherit; text-decoration: none; }
+.ch-about-card-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden;
+ background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform .2s cubic-bezier(.22,1,.36,1), box-shadow .2s, border-color .2s; }
+.ch-about-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.ch-about-card:hover .ch-about-card-thumb, .ch-about-card:focus-visible .ch-about-card-thumb { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
+ box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 16%, rgba(0,0,0,.35)); }
+.ch-about-card-dur { position: absolute; right: 6px; bottom: 6px; padding: 2px 6px; border-radius: 6px; font-size: 0.68rem; font-weight: 700; color: #fff; background: rgba(0,0,0,.72); }
+.ch-about-card-title { font-size: 0.88rem; font-weight: 650; line-height: 1.3; color: var(--text-primary);
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.ch-about-card-meta { font-size: 0.75rem; color: var(--text-muted); }
+@media (prefers-reduced-motion: reduce) { .ch-about-card-thumb, .ch-about-more i { transition: none; } }
+
+/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer
+ whenever the open tab was short. */
+#page-channel.page { min-height: 0; }
+
+/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */
+.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }
+
+/* A custom offline image with the "most watched" card over it: when the column is narrow the card no longer fits over
+ the image (it was clipped), so the image becomes a banner and the card follows it in the page. */
+.ch-offline-area .stream-main { container: chmain / inline-size; }
+@container chmain (max-width: 720px) {
+ .ch-offline-screen:has(> .ch-offline-overlay) { aspect-ratio: auto; max-height: none; display: block; overflow: visible; background: transparent; border: 0; border-radius: 0; }
+ .ch-offline-screen:has(> .ch-offline-overlay) > .ch-offline-media { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; border: 1px solid var(--border); }
+ .ch-offline-screen > .ch-offline-overlay { position: static; inset: auto; display: flex !important; background: none; padding: 16px 0 0; pointer-events: auto; }
+ .ch-offline-overlay-close, .ch-offline-reopen { display: none !important; }
+ .ch-offline-overlay .off-cyc { padding: 0; }
+ .ch-offline-overlay .off-cyc-title { color: var(--text-primary); }
+}
+
+/* Tabs that run past the edge fade out there, so it reads as "scroll for more" (class set by app-channel.js). */
+.ch-tabs.is-clipped { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); }
@@ -582,8 +582,20 @@ function _restoreChannelChat() {
@@ -582,8 +582,20 @@ function _restoreChannelChat() {
toggleChannelChat(hidden);
toggleChannelChat(hidden);
}
}
+/** Fade the tab row's right edge while more tabs are hidden past it. */
+function _watchTabsClip() {
+ const t = document.getElementById('ch-tabs');
+ if (!t || t._ovClipWatch) return;
+ t._ovClipWatch = true;
+ const upd = () => t.classList.toggle('is-clipped', t.scrollWidth - t.clientWidth - t.scrollLeft > 4);
+ t.addEventListener('scroll', upd, { passive: true });
+ if ('ResizeObserver' in window) new ResizeObserver(upd).observe(t); else window.addEventListener('resize', upd);
+ upd();
+}
+
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
_restoreChannelChat();
_restoreChannelChat();
+ _watchTabsClip();
try {
try {
const isNewChannel = currentChannelUsername !== username;
const isNewChannel = currentChannelUsername !== username;
currentChannelUsername = username;
currentChannelUsername = username;
@@ -623,6 +635,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -623,6 +635,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
const vods = data.vods || [];
const vods = data.vods || [];
const clips = data.clips || [];
const clips = data.clips || [];
const clipsOfStreams = data.clipsOfStreams || [];
const clipsOfStreams = data.clipsOfStreams || [];
+ // About's "Latest videos" / "Popular clips" rows reuse what this load already fetched.
+ _aboutRecent = { username, vods: vods.slice(0, 8), clips: clipsOfStreams.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0)).slice(0, 8) };
+ _renderAboutRecent();
const managedStreams = data.managed_streams || [];
const managedStreams = data.managed_streams || [];
const liveStreams = streams.filter(s => s && s.is_live);
const liveStreams = streams.filter(s => s && s.is_live);
const rsRestream = data.rs_restream || {};
const rsRestream = data.rs_restream || {};
@@ -2283,20 +2298,47 @@ function _aboutAiEffectiveShow() {
@@ -2283,20 +2298,47 @@ function _aboutAiEffectiveShow() {
if (_aboutAiPref === 'hide') return false;
if (_aboutAiPref === 'hide') return false;
return !(_aboutBio || (_aboutPanels && _aboutPanels.length)); // auto → only when no bio/panels
return !(_aboutBio || (_aboutPanels && _aboutPanels.length)); // auto → only when no bio/panels
}
}
-// The AI overview card shown at the top of the About tab (view mode).
+// The AI overview in the About tab (view mode): a quiet note under the streamer's own words, not a card.
function _aboutAiOverviewHTML() {
function _aboutAiOverviewHTML() {
if (!_aboutAiEffectiveShow()) return '';
if (!_aboutAiEffectiveShow()) return '';
- return `<div class="ai-tl-overview-card about-ai-overview">
- <div class="ai-tl-overview-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Overview</div>
+ return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">
+ <h3 class="ch-about-h"><i class="fa-solid fa-wand-magic-sparkles"></i> AI summary <span class="ch-about-h-note">from past streams</span></h3>
${_collapsibleOverview(_aboutAiOverview)}
${_collapsibleOverview(_aboutAiOverview)}
- </div>`;
+ </section>`;
+}
+
+// "Latest videos" and "Popular clips" rows at the end of About: something to watch on every channel, and no screen of
+// empty space under a one-line bio. Filled from the channel load (_aboutRecent), re-rendered with About.
+let _aboutRecent = { username: null, vods: [], clips: [] };
+function _aboutRowCard(kind, it) {
+ const isVod = kind === 'vod';
+ const href = isVod ? `/vod/${it.id}` : `/clip/${it.id}`;
+ const gen = isVod ? `/api/thumbnails/generate/vod/${it.id}` : `/api/thumbnails/generate/clip/${it.id}`;
+ const views = Number(it.view_count || 0);
+ return `<a class="ch-about-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
+ <span class="ch-about-card-thumb">${thumbImg(it.thumbnail_url, isVod ? 'fa-video' : 'fa-scissors', it.title, gen)}${it.duration_seconds ? `<span class="ch-about-card-dur">${formatDuration(it.duration_seconds)}</span>` : ''}</span>
+ <span class="ch-about-card-title">${esc(it.title || (isVod ? 'Stream' : 'Clip'))}</span>
+ <span class="ch-about-card-meta">${views.toLocaleString()} view${views === 1 ? '' : 's'} · ${timeAgo(it.created_at)}</span>
+ </a>`;
+}
+function _renderAboutRecent() {
+ const host = document.getElementById('ch-about-recent');
+ if (!host) return;
+ const r = _aboutRecent;
+ if (!r || r.username !== currentChannelUsername) { host.innerHTML = ''; return; }
+ const row = (title, icon, tab, kind, items) => items.length ? `<section class="ch-about-sec">
+ <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'))">See all <i class="fa-solid fa-arrow-right"></i></button></h3>
+ <div class="ch-about-row">${items.map((it) => _aboutRowCard(kind, it)).join('')}</div>
+ </section>` : '';
+ host.innerHTML = row('Latest videos', 'fa-video', 'videos', 'vod', r.vods) + row('Popular clips', 'fa-scissors', 'clips', 'clip', r.clips);
}
}
function _renderAboutView() {
function _renderAboutView() {
const host = document.getElementById('ch-about-content');
const host = document.getElementById('ch-about-content');
if (!host) return;
if (!host) return;
const aiHtml = _aboutAiOverviewHTML();
const aiHtml = _aboutAiOverviewHTML();
- const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length;
+ const hasRecent = _aboutRecent.username === currentChannelUsername && (_aboutRecent.vods.length || _aboutRecent.clips.length);
+ const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length || hasRecent;
let html = '';
let html = '';
if (!hasContent) {
if (!hasContent) {
html += _aboutCanEdit
html += _aboutCanEdit
@@ -2305,11 +2347,14 @@ function _renderAboutView() {
@@ -2305,11 +2347,14 @@ function _renderAboutView() {
host.innerHTML = html;
host.innerHTML = html;
return;
return;
}
}
- html += aiHtml; // AI overview card leads the About tab
+ // The streamer's own words lead; the AI summary follows as a note; then links, panels, and something to watch.
if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;
if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;
+ html += aiHtml;
html += '<section class="ch-socials" id="ch-about-socials" hidden></section>';
html += '<section class="ch-socials" id="ch-about-socials" hidden></section>';
- html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';
+ if (_aboutPanels.length) html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';
+ html += '<div id="ch-about-recent"></div>';
host.innerHTML = html;
host.innerHTML = html;
+ _renderAboutRecent();
if (window.ChannelSocials) ChannelSocials.cards(document.getElementById('ch-about-socials'), _aboutSocials, currentChannelUsername);
if (window.ChannelSocials) ChannelSocials.cards(document.getElementById('ch-about-socials'), _aboutSocials, currentChannelUsername);
_fillWeatherPanels();
_fillWeatherPanels();
void _fillBioTranslation();
void _fillBioTranslation();