openvibers/openvibe.live Commits
Organize live and offline channel headers (#723)
Changes
@@@ -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 buttonsize (40 px, 44 on a touch screen) with the icon centred at 18 px; LIVE is a red pill with a breathing dot (a greyglass pill when you are behind, "Go live"); the viewer count and the uptime are glass chips at a readable size;@@@ -1078,7 +1078,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIsetupFollowBtn(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);@@@ -35,7 +35,7 @@ ok('a direct visit shows the route\'s own section, and the router starts from itconst 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 }")');
@@ -1971,6 +1971,22 @@ html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none
@@ -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; } }
@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; } }
@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
/* ── 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
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;
glass pill when you are behind, "Go live"); the viewer count and the uptime are glass chips at a readable size;
@@ -1078,7 +1078,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -1078,7 +1078,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
// Customizable offline screen (image / video / custom HTML)
// 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
// Offline: join the streamer's PERSISTENT chat room (not global) so
// viewers can keep chatting + see history while the streamer is offline.
// viewers can keep chatting + see history while the streamer is offline.
@@ -3634,7 +3634,18 @@ function _setWatchAlert(a, username) {
@@ -3634,7 +3634,18 @@ function _setWatchAlert(a, username) {
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// 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.
// 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.
// 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');
const host = document.getElementById('ch-offline-screen');
if (!host) return;
if (!host) return;
_stopOfflineCycler(); // clear any prior offline cycler before re-rendering
_stopOfflineCycler(); // clear any prior offline cycler before re-rendering
@@ -3671,10 +3682,18 @@ function _renderOfflineScreen(ch) {
@@ -3671,10 +3682,18 @@ function _renderOfflineScreen(ch) {
} else {
} else {
const av = _avatarInner(ch && ch.avatar_url, ch && (ch.display_name || ch.username));
const av = _avatarInner(ch && ch.avatar_url, ch && (ch.display_name || ch.username));
const name = esc((ch && (ch.display_name || ch.username)) || 'Streamer');
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">
host.innerHTML = `<div class="ch-offline-default">
<div class="ch-offline-default-avatar">${av}</div>
<div class="ch-offline-default-avatar">${av}</div>
<div class="ch-offline-default-name">${name}</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 class="ch-offline-explore" id="ch-offline-explore"></div>
</div>`;
</div>`;
return _fillOfflineExplore(ch && ch.username);
return _fillOfflineExplore(ch && ch.username);
@@ -35,7 +35,7 @@ ok('a direct visit shows the route\'s own section, and the router starts from it
@@ -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 chJs = read('public/js/app-channel.js');
const chCss = read('public/css/features/channel.css');
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(/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(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: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 }")');
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 }")');