openvibers/openvibe.live Commits

AI timeline: one label, long write-ups fold, a chatter's moments as timed rows (#827)

OP OpenVibers committed 2 days ago · 3e7b340c6f · 2 files, +40 −17

- With one side only, its card's own "As a streamer" / "As a chatter" is the label (it said it twice)
- A session's long write-up folds after four lines like the overview cards (Show more; the text stays in the page)
- A chatter's day: its date ("Tue, Nov 10"), messages and moments counted, the summary, then each moment as a row
  with its time and a play mark that opens the recording at that second; past five the rest fold under "N more"
  (they were cut at five before)
Maticus's panel at a phone's width: 5,340 to 3,117 px.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex ca656f92..785618f5 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1218,6 +1218,21 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ai-tl-session-meta { color: var(--text-muted); font-size: 0.78rem; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
.ai-tl-dot { opacity: 0.4; margin: 0 2px; }
.ai-tl-session-overview { margin: 8px 0 10px; color: var(--text-secondary); line-height: 1.5; font-size: 0.9rem; white-space: pre-wrap; }
++/* A chatter's day (Opus, 2026-11-11): its moments as rows, each its time, what happened and a play mark when it opens
++ the recording at that second; past five they fold under "N more". */
++.ai-tl-cms { list-style: none; margin: 6px 0 8px; padding: 0; display: grid; gap: 4px; }
++.ai-tl-cm { display: flex; align-items: baseline; gap: 10px; padding: 7px 10px; border-radius: 10px; color: var(--text-primary); text-decoration: none; font-size: .88rem; line-height: 1.45; background: color-mix(in srgb, var(--text-primary, #fff) 3%, transparent); }
++a.ai-tl-cm:hover { background: color-mix(in srgb, var(--accent, #8b5cf6) 12%, transparent); }
++a.ai-tl-cm:focus-visible { outline: 2px solid var(--accent, #8b5cf6); outline-offset: 1px; }
++.ai-tl-cm .ai-tl-ts { min-width: 54px; padding-top: 0; }
++.ai-tl-cm-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
++.ai-tl-cm-go { flex: none; align-self: center; font-size: .7rem; color: var(--accent, #8b5cf6); opacity: .7; }
++a.ai-tl-cm:hover .ai-tl-cm-go { opacity: 1; }
++.ai-tl-cms-more > summary { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 10px; cursor: pointer; color: var(--accent, #8b5cf6); font-size: .85rem; font-weight: 600; list-style: none; }
++.ai-tl-cms-more > summary::-webkit-details-marker { display: none; }
++.ai-tl-cms-more > summary::after { content: ""; width: 6px; height: 6px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); margin-top: -3px; }
++.ai-tl-cms-more[open] > summary::after { transform: rotate(-135deg); margin-top: 3px; }
++.ai-tl-session-overview .ai-ov-text { white-space: pre-wrap; }
.ai-tl-moments { display: flex; flex-direction: column; gap: 8px; }
.ai-tl-moment-thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: rgba(0,0,0,0.3); }
.ai-tl-stamp { align-self: flex-start; font-size: 0.75rem; font-weight: 700; color: var(--accent, #8b5cf6); background: color-mix(in srgb, var(--accent, #8b5cf6) 12%, transparent); padding: 2px 8px; border-radius: 8px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; }
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex f2212039..54587c89 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2730,7 +2730,7 @@ function _aiTimelineSessionHTML(s) {
<div class="ai-tl-session-title">${mainTitle}</div>
<div class="ai-tl-session-meta">${meta.join('<span class="ai-tl-dot">·</span>')}</div>
</div>
-- ${ov ? `<div class="ai-tl-session-overview">${esc(ov)}</div>` : ''}
++ ${ov ? `<div class="ai-tl-session-overview">${_collapsibleOverview(ov)}</div>` : ''}
<div class="ai-tl-session-actions">${momentsBtn}${transcriptBtn}</div>
<div class="ai-tl-moments" hidden></div>
<div class="ai-tl-transcript" hidden></div>
@@@ -2989,22 +2989,30 @@ async function loadChannelAiTimeline(username) {
// ── As a chatter (inner content) ──
let chatterInner = '';
if (hasChatter) {
-- const momentsHTML = chatDays.length ? `<div class="ai-tl-track">${chatDays.map(t => `
++ const dayLabel = (d) => { const t = Date.parse(String(d || '').slice(0, 10) + 'T12:00:00Z'); return Number.isFinite(t) ? new Date(t).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: new Date(t).getUTCFullYear() === new Date().getUTCFullYear() ? undefined : 'numeric', timeZone: 'UTC' }) : String(d || ''); };
++ const parseTime = value => value ? Date.parse(String(value).replace(' ', 'T').replace(/Z?$/, 'Z')) : NaN;
++ const momentRow = (h) => {
++ const session = [...sessions, ...(data.index || [])].find(s => Number(s.id) === Number(h.stream_id));
++ const started = parseTime(session && (session.started_at || session.created_at || session.when));
++ const at = parseTime(h.at);
++ const offset = Number.isFinite(started) && Number.isFinite(at) ? Math.max(0, Math.floor((at - started) / 1000)) : 0;
++ const href = session?.vod_id ? videoHref('vod', session.vod_id, offset) : '';
++ const clock = Number.isFinite(at) ? new Date(at).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '';
++ const inner = `${clock ? `<span class="ai-tl-ts">${esc(clock)}</span>` : ''}<span class="ai-tl-cm-text">${esc(h.detail || '')}</span>${href ? '<i class="fa-solid fa-play ai-tl-cm-go" aria-hidden="true"></i>' : ''}`;
++ return `<li>${href ? `<a class="ai-tl-cm" href="${esc(href)}" title="Watch this moment">${inner}</a>` : `<span class="ai-tl-cm">${inner}</span>`}</li>`;
++ };
++ const momentsHTML = chatDays.length ? `<div class="ai-tl-track">${chatDays.map(t => {
++ const hs = t.highlights || [];
++ return `
<div class="ai-tl-session">
<div class="ai-tl-session-head"><div class="ai-tl-node"></div>
-- <div class="ai-tl-session-title">${esc(t.period_start)}</div>
-- <div class="ai-tl-session-meta">${t.message_count ? `${_fmtCount(t.message_count)} messages` : `${t.highlights.length} moments`}</div></div>
-- ${t.summary ? `<div class="ai-tl-session-overview">${esc(t.summary)}</div>` : ''}
-- ${(t.highlights || []).slice(0, 5).map(h => {
-- const session = [...sessions, ...(data.index || [])].find(s => Number(s.id) === Number(h.stream_id));
-- const parseTime = value => value ? Date.parse(String(value).replace(' ', 'T').replace(/Z?$/, 'Z')) : NaN;
-- const started = parseTime(session && (session.started_at || session.created_at || session.when));
-- const at = parseTime(h.at);
-- const offset = Number.isFinite(started) && Number.isFinite(at) ? Math.max(0, Math.floor((at - started) / 1000)) : 0;
-- const href = session?.vod_id ? videoHref('vod', session.vod_id, offset) : '';
-- return `<div class="ai-tl-session-overview">${href ? `<a href="${esc(href)}">${esc(h.detail || '')}</a>` : esc(h.detail || '')}</div>`;
-- }).join('')}
-- </div>`).join('')}</div>` : '';
++ <div class="ai-tl-session-title">${esc(dayLabel(t.period_start))}</div>
++ <div class="ai-tl-session-meta">${t.message_count ? `${_fmtCount(t.message_count)} messages` : ''}${t.message_count && hs.length ? ' · ' : ''}${hs.length ? `${hs.length} moment${hs.length === 1 ? '' : 's'}` : ''}</div></div>
++ ${t.summary ? `<div class="ai-tl-session-overview">${_collapsibleOverview(t.summary)}</div>` : ''}
++ ${hs.length ? `<ol class="ai-tl-cms">${hs.slice(0, 5).map(momentRow).join('')}</ol>` : ''}
++ ${hs.length > 5 ? `<details class="ai-tl-cms-more"><summary>${hs.length - 5} more moment${hs.length - 5 === 1 ? '' : 's'}</summary><ol class="ai-tl-cms">${hs.slice(5).map(momentRow).join('')}</ol></details>` : ''}
++ </div>`;
++ }).join('')}</div>` : '';
chatterInner = (chatOverall ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-comments"></i> As a chatter</div>${_collapsibleOverview(chatOverall)}${chat.message_count ? `<p class="ai-tl-summary" style="margin:8px 0 0">~${chat.message_count} messages analyzed</p>` : ''}</div>` : '')
+ momentsHTML;
}
@@@ -3021,9 +3029,9 @@ async function loadChannelAiTimeline(username) {
<div class="ai-tl-pane" data-side="streamer">${streamerInner}</div>
<div class="ai-tl-pane ai-tl-pane--hidden" data-side="chatter">${chatterInner}</div>`;
} else if (hasStreamer) {
-- sidesHTML = `<div class="ai-tl-side-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>${streamerInner}`;
++ sidesHTML = streamerOv ? streamerInner : `<div class="ai-tl-side-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>${streamerInner}`;
} else {
-- sidesHTML = `<div class="ai-tl-side-label"><i class="fa-solid fa-comments"></i> As a chatter</div>${chatterInner}`;
++ sidesHTML = chatOverall ? chatterInner : `<div class="ai-tl-side-label"><i class="fa-solid fa-comments"></i> As a chatter</div>${chatterInner}`;
}
wrap.innerHTML = header + sidesHTML;