openvibers/openvibe.live Commits

Organize live and offline channel headers (#723)

OP OpenVibers committed 4 days ago · 54514e2ff0 · 3 files, +39 −4

* Unify channel header actions and metadata

* Channel offline: when they were last live, said elegantly, and the last stream one tap away (ask 57)

- The header says "Last live 7d ago" beside the followers (a clock, the full date on hover), whatever the channel's
  offline screen is.
- The default offline card says "Offline · last live 7d ago" in a quiet pill (was "is offline — explore their top
  content, or say hi in chat") and, when there is a recording, offers it: a play button, its title and its length.
- When: the newest end among the channel's slots, or its newest recording.

* Channel offline: the header's Last live line comes from the sections work (#730); this keeps the offline card

* Merge remote-tracking branch 'origin/main' into live-channel-header-1109

# Conflicts:
#	public/css/features/channel.css

* Run CI on the merged head

* First-paint test: the offline render may take the channel's data

* Merge remote-tracking branch 'origin/main' into live-channel-header-1109

# Conflicts:
#	public/css/features/channel.css
#	public/fragments/channel.html
#	public/js/app-channel.js
#	public/js/channel-socials.js
#	test/channel-socials-pills.test.js

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 1b2ec181..d9a95fdf 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1971,6 +1971,22 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
@keyframes chChatRise { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none !important; } }
++/* Offline, with when (ask 57, 11-09): a quiet state line and the last stream one tap away (the header says "Last live" too). */
++.ch-offline-state { display: inline-flex; align-items: center; gap: 8px; margin: 8px 0 0; padding: 6px 14px; border-radius: 999px; font-size: .9rem; font-weight: 600;
++ color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
++.ch-offline-state time { color: var(--text-primary); font-weight: 700; }
++.ch-offline-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-muted) 22%, transparent); }
++.ch-last-stream { display: flex; align-items: center; gap: 12px; max-width: min(100%, 420px); margin: 14px auto 0; padding: 10px 16px 10px 10px; border-radius: 16px; text-align: left;
++ color: inherit; text-decoration: none; background: color-mix(in srgb, var(--accent) 10%, var(--bg-card, transparent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent);
++ transition: transform .18s cubic-bezier(.2, .9, .3, 1.2), background .18s; }
++.ch-last-stream:hover { background: color-mix(in srgb, var(--accent) 16%, var(--bg-card, transparent)); transform: translateY(-1px); }
++.ch-last-play { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--accent); color: var(--on-accent, #fff); font-size: .95rem; }
++.ch-last-play i { margin-left: 2px; }
++.ch-last-text { min-width: 0; display: grid; gap: 2px; }
++.ch-last-text b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .95rem; color: var(--text-primary); }
++.ch-last-text small { font-size: .78rem; color: var(--text-muted); }
++@media (prefers-reduced-motion: reduce) { .ch-last-stream { transition: none; } }
++
/* ── The player's bar, 11-09 (the owner: "the buttons and the icons… better sized and centred… better tags"). One button
size (40 px, 44 on a touch screen) with the icon centred at 18 px; LIVE is a red pill with a breathing dot (a grey
glass pill when you are behind, "Go live"); the viewer count and the uptime are glass chips at a readable size;
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 38c15429..de302ce0 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -1078,7 +1078,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
// Customizable offline screen (image / video / custom HTML)
-- offlineBlocks = _renderOfflineScreen(ch);
++ offlineBlocks = _renderOfflineScreen(ch, data);
// Offline: join the streamer's PERSISTENT chat room (not global) so
// viewers can keep chatting + see history while the streamer is offline.
@@@ -3634,7 +3634,18 @@ function _setWatchAlert(a, username) {
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// streamer's markup can't touch viewers' session). Falls back to a tasteful default.
// Resolves once the offline blocks that land above the tabs ("Most watched", the discover board) are in.
--function _renderOfflineScreen(ch) {
++/** When the channel was last on air (ask 57): the newest end among its slots, or its newest recording; and that recording. */
++function _lastLive(data) {
++ const t = (x) => { if (!x) return 0; const s = String(x); const n = Date.parse(s.includes('T') ? s : s.replace(' ', 'T') + 'Z'); return Number.isFinite(n) ? n : 0; };
++ const vod = data && Array.isArray(data.vods) && data.vods.length ? data.vods[0] : null;
++ const cands = ((data && Array.isArray(data.managed_streams)) ? data.managed_streams : []).map((m) => m && m.last_live_at);
++ if (vod) cands.push(vod.ended_at || vod.created_at || vod.started_at);
++ const best = cands.filter(Boolean).map((x) => [x, t(x)]).filter((p) => p[1]).sort((a, b) => b[1] - a[1])[0];
++ return { at: best ? best[0] : null, ms: best ? best[1] : 0, vod };
++}
++
++
++function _renderOfflineScreen(ch, data) {
const host = document.getElementById('ch-offline-screen');
if (!host) return;
_stopOfflineCycler(); // clear any prior offline cycler before re-rendering
@@@ -3671,10 +3682,18 @@ function _renderOfflineScreen(ch) {
} else {
const av = _avatarInner(ch && ch.avatar_url, ch && (ch.display_name || ch.username));
const name = esc((ch && (ch.display_name || ch.username)) || 'Streamer');
++ // Offline, said with when (ask 57: "show when they were last live in a nice elegant way"), and the last stream
++ // one tap away when there is a recording of it.
++ const last = _lastLive(data);
++ const when = last.at ? ` · last live <time datetime="${esc(new Date(last.ms).toISOString())}" title="${esc(new Date(last.ms).toLocaleString())}">${esc(timeAgo(last.at))}</time>` : '';
++ const v = last.vod;
++ const secs = v && Number(v.duration_seconds || v.duration || 0);
++ const lastStream = v && v.id ? `<a class="ch-last-stream" href="${videoHref('vod', v.id)}"><span class="ch-last-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span><span class="ch-last-text"><b>${esc(v.title || 'The last stream')}</b><small>${secs ? `${esc(formatDuration(secs))} · ` : ''}the last stream</small></span></a>` : '';
host.innerHTML = `<div class="ch-offline-default">
<div class="ch-offline-default-avatar">${av}</div>
<div class="ch-offline-default-name">${name}</div>
-- <div class="ch-offline-default-sub">is offline — explore their top content, or say hi in chat</div>
++ <p class="ch-offline-state"><span class="ch-offline-dot" aria-hidden="true"></span>Offline${when}</p>
++ ${lastStream}
<div class="ch-offline-explore" id="ch-offline-explore"></div>
</div>`;
return _fillOfflineExplore(ch && ch.username);
ddiff --git a/test/first-paint-stability.test.js b/test/first-paint-stability.test.js
iindex 8642c10b..7fc22ad9 100644
---- a/test/first-paint-stability.test.js
++++ b/test/first-paint-stability.test.js
@@@ -35,7 +35,7 @@ ok('a direct visit shows the route\'s own section, and the router starts from it
const chJs = read('public/js/app-channel.js');
const chCss = read('public/css/features/channel.css');
assert.ok(/function _channelReady\(ready = true\)/.test(chJs) && chJs.includes("page.classList.add('ch-data')") && chJs.includes('setTimeout(_channelReady, 2500)'), 'the channel marks its data and readiness, with a cap');
--assert.ok(chJs.includes('offlineBlocks = _renderOfflineScreen(ch);') && chJs.includes('Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());'), 'ready waits for the offline blocks above the tabs');
++assert.ok(/offlineBlocks = _renderOfflineScreen\(ch(?:, data)?\);/.test(chJs) && chJs.includes('Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());'), 'ready waits for the offline blocks above the tabs');
assert.ok(chJs.includes('return _fillOfflineExplore(ch && ch.username);') && chJs.includes('return renderOfflineDiscover(username, d);') && chJs.includes('return discovered;'), 'the offline screen resolves after "Most watched" and the discover board');
assert.ok(chCss.includes('#page-channel:not(.ch-ready) .channel-below-fold { display: none; }'), 'tabs out of the layout until ready');
assert.ok(chCss.includes('#page-channel.page:not(.ch-ready) { min-height: 100vh; }'), 'the page keeps a full viewport until ready (beats "#page-channel.page { min-height: 0 }")');