openvibers/openvibe.live Commits
Recently Online: an offline stream's row opens its replay on openvibe.video (the chat in time beside it), with a play mark; live rows and rows with no recording stay the channel (#409)
Changes
@@@ -1219,3 +1219,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }++++/* Recently Online: a stream that has a recording opens its replay (openvibe.video, the chat in time beside it), and++ its picture says so with a play mark (2026-10-27). */++.streamer-group-stream-thumb { position: relative; }++.sg-play {++ position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none;++ background: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));++}++.sg-play i {++ display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; padding-left: 2px; box-sizing: border-box;++ font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(255, 255, 255, 0.35);++ transition: transform 0.15s, background 0.15s;++}++.streamer-group-stream.has-replay:hover .sg-play i, .streamer-group-stream.has-replay:focus-visible .sg-play i { transform: scale(1.08); background: var(--accent); border-color: transparent; }++.sg-replay { color: var(--accent-light, var(--accent)); font-weight: 700; }++@media (prefers-reduced-motion: reduce) { .sg-play i { transition: none; } }@@@ -1444,7 +1444,11 @@ function renderRecentlyOnline(containerId, streamers, append) {const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);const channelHref = `/@${s.username}`;const streamsHtml = msList.length ? msList.map(ms => {-- const href = channelPath(s.username, ms.slug || ms.managed_stream_id);++ // An offline stream opens its replay on openvibe.video, the chat playing in time beside it: the row showed a++ // picture of the stream and led to an offline channel (owner, 2026-10-27). Live, or with no recording, the++ // row is the channel; the card's name always is.++ const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;++ const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);// A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load// becomes the placeholder (_sgThumbFail) instead of a broken image.const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);@@@ -1453,10 +1457,11 @@ function renderRecentlyOnline(containerId, streamers, append) {: _SG_THUMB_PLACEHOLDER;const when = ms.is_live? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`-- : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}`;++ : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}${replay ? ' · <span class="sg-replay">Replay</span>' : ''}`;++ const label = replay ? `Watch the replay of ${ms.title || 'this stream'}` : ms.is_live ? `Watch ${ms.title || 'the stream'} live` : `${ms.title || 'Stream'} on ${s.display_name || s.username}'s channel`;return `-- <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}" href="${href}" onclick="return handleLinkClick(event, '${href}')">-- <div class="streamer-group-stream-thumb">${thumb}</div>++ <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}${replay ? ' has-replay' : ''}" href="${href}" aria-label="${esc(label)}" onclick="return handleLinkClick(event, '${href}')">++ <div class="streamer-group-stream-thumb">${thumb}${replay ? '<span class="sg-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>' : ''}</div><div class="streamer-group-stream-info"><div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div><div class="streamer-group-stream-time muted">${when}</div>@@@ -1243,6 +1243,8 @@ router.get('/recently-online', async (req, res) => {for (const ms of (s.managed_streams || [])) {const t = thumbs[ms.managed_stream_id];if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);++ // The recording the row's picture is from: the row opens its replay (with the chat in time).++ if (t && t.vod_id && !ms.vod_id) ms.vod_id = t.vod_id;}}}@@@ -1255,7 +1257,11 @@ router.get('/recently-online', async (req, res) => {if (loose.length) {const media = require('../media-client');const vods = await require('../media-proxy/lookups').publicVodsForStreams(loose.map(ms => ms.stream_id), { budgetMs: 3000 });-- loose.forEach((ms) => { const v = vods.get(Number(ms.stream_id)); if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url); });++ loose.forEach((ms) => {++ const v = vods.get(Number(ms.stream_id));++ if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url);++ if (v && v.id) ms.vod_id = v.id;++ });}} catch (err) {console.warn('[Streaming] recently-online slot-less thumbnails failed:', err.message);@@@ -63,6 +63,7 @@ const db = require('../server/db/database');const dz = body.streamers.find((s) => s.username === 'dreezy');assert.deepStrictEqual(dz.managed_streams.map((m) => [m.slug, m.title, !!m.last_live_at]), [[null, 'old', true]]);assert.ok(String(dz.managed_streams[0].vod_thumbnail).endsWith('/t/dreezy-part-2.jpg'), "with its recording's picture");++ assert.strictEqual(dz.managed_streams[0].vod_id, 9, 'and the recording itself: the row opens its replay (2026-10-27)');// Next to slots, an account-key stream is listed only while it is what they streamed last: Go Live lists slots,// and a months-old one under them read as a slot nobody could find (owner, 2026-10-27).const go = body.streamers.find((s) => s.username === 'goosely');@@@ -74,3 +75,15 @@ const db = require('../server/db/database');} finally { server.close(); }process.exit(0);})().catch((e) => { console.error(e); process.exit(1); });++++// The card (public/js/app-home.js renderRecentlyOnline): an offline row with a recording opens its replay on++// openvibe.video, with a play mark; a live row, or one with no recording, is the channel (owner, 2026-10-27).++{++ const fs = require('fs');++ const path = require('path');++ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');++ const body = src.slice(src.indexOf('function renderRecentlyOnline('), src.indexOf('async function loadHomeRecentVods('));++ assert.ok(body.includes("const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;"));++ assert.ok(body.includes('const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);'));++ assert.ok(body.includes('<span class="sg-play" aria-hidden="true">') && body.includes('<span class="sg-replay">Replay</span>'));++}
@@ -1219,3 +1219,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1219,3 +1219,19 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }
.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }
.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }
.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }
@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }
@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }
+
+/* Recently Online: a stream that has a recording opens its replay (openvibe.video, the chat in time beside it), and
+ its picture says so with a play mark (2026-10-27). */
+.streamer-group-stream-thumb { position: relative; }
+.sg-play {
+ position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none;
+ background: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.25));
+}
+.sg-play i {
+ display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; padding-left: 2px; box-sizing: border-box;
+ font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(255, 255, 255, 0.35);
+ transition: transform 0.15s, background 0.15s;
+}
+.streamer-group-stream.has-replay:hover .sg-play i, .streamer-group-stream.has-replay:focus-visible .sg-play i { transform: scale(1.08); background: var(--accent); border-color: transparent; }
+.sg-replay { color: var(--accent-light, var(--accent)); font-weight: 700; }
+@media (prefers-reduced-motion: reduce) { .sg-play i { transition: none; } }
@@ -1444,7 +1444,11 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1444,7 +1444,11 @@ function renderRecentlyOnline(containerId, streamers, append) {
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const channelHref = `/@${s.username}`;
const channelHref = `/@${s.username}`;
const streamsHtml = msList.length ? msList.map(ms => {
const streamsHtml = msList.length ? msList.map(ms => {
- const href = channelPath(s.username, ms.slug || ms.managed_stream_id);
+ // An offline stream opens its replay on openvibe.video, the chat playing in time beside it: the row showed a
+ // picture of the stream and led to an offline channel (owner, 2026-10-27). Live, or with no recording, the
+ // row is the channel; the card's name always is.
+ const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;
+ const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);
// A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load
// A live slot shows its current frame; otherwise the latest recording's. A picture that fails to load
// becomes the placeholder (_sgThumbFail) instead of a broken image.
// becomes the placeholder (_sgThumbFail) instead of a broken image.
const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);
const src = _sgThumbSrc(ms.is_live && ms.live_thumbnail ? ms.live_thumbnail : ms.vod_thumbnail);
@@ -1453,10 +1457,11 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1453,10 +1457,11 @@ function renderRecentlyOnline(containerId, streamers, append) {
: _SG_THUMB_PLACEHOLDER;
: _SG_THUMB_PLACEHOLDER;
const when = ms.is_live
const when = ms.is_live
? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`
? `<span class="sg-live"><span class="sg-live-dot"></span> Live now</span>${ms.live_started_at ? ` · ${formatUptime(ms.live_started_at)}` : ''}`
- : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}`;
+ : `<i class="fa-solid fa-clock"></i> ${ms.last_live_at ? timeAgo(ms.last_live_at) : 'long ago'}${replay ? ' · <span class="sg-replay">Replay</span>' : ''}`;
+ const label = replay ? `Watch the replay of ${ms.title || 'this stream'}` : ms.is_live ? `Watch ${ms.title || 'the stream'} live` : `${ms.title || 'Stream'} on ${s.display_name || s.username}'s channel`;
return `
return `
- <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}" href="${href}" onclick="return handleLinkClick(event, '${href}')">
- <div class="streamer-group-stream-thumb">${thumb}</div>
+ <a class="streamer-group-stream${ms.is_live ? ' is-live' : ''}${replay ? ' has-replay' : ''}" href="${href}" aria-label="${esc(label)}" onclick="return handleLinkClick(event, '${href}')">
+ <div class="streamer-group-stream-thumb">${thumb}${replay ? '<span class="sg-play" aria-hidden="true"><i class="fa-solid fa-play"></i></span>' : ''}</div>
<div class="streamer-group-stream-info">
<div class="streamer-group-stream-info">
<div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>
<div class="streamer-group-stream-title">${esc(ms.title || 'Stream')}</div>
<div class="streamer-group-stream-time muted">${when}</div>
<div class="streamer-group-stream-time muted">${when}</div>
@@ -1243,6 +1243,8 @@ router.get('/recently-online', async (req, res) => {
@@ -1243,6 +1243,8 @@ router.get('/recently-online', async (req, res) => {
for (const ms of (s.managed_streams || [])) {
for (const ms of (s.managed_streams || [])) {
const t = thumbs[ms.managed_stream_id];
const t = thumbs[ms.managed_stream_id];
if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);
if (t && !ms.vod_thumbnail) ms.vod_thumbnail = media.publicUrl(t.thumbnail_url);
+ // The recording the row's picture is from: the row opens its replay (with the chat in time).
+ if (t && t.vod_id && !ms.vod_id) ms.vod_id = t.vod_id;
}
}
}
}
}
}
@@ -1255,7 +1257,11 @@ router.get('/recently-online', async (req, res) => {
@@ -1255,7 +1257,11 @@ router.get('/recently-online', async (req, res) => {
if (loose.length) {
if (loose.length) {
const media = require('../media-client');
const media = require('../media-client');
const vods = await require('../media-proxy/lookups').publicVodsForStreams(loose.map(ms => ms.stream_id), { budgetMs: 3000 });
const vods = await require('../media-proxy/lookups').publicVodsForStreams(loose.map(ms => ms.stream_id), { budgetMs: 3000 });
- loose.forEach((ms) => { const v = vods.get(Number(ms.stream_id)); if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url); });
+ loose.forEach((ms) => {
+ const v = vods.get(Number(ms.stream_id));
+ if (v && v.thumbnail_url) ms.vod_thumbnail = media.publicUrl(v.thumbnail_url);
+ if (v && v.id) ms.vod_id = v.id;
+ });
}
}
} catch (err) {
} catch (err) {
console.warn('[Streaming] recently-online slot-less thumbnails failed:', err.message);
console.warn('[Streaming] recently-online slot-less thumbnails failed:', err.message);
@@ -63,6 +63,7 @@ const db = require('../server/db/database');
@@ -63,6 +63,7 @@ const db = require('../server/db/database');
const dz = body.streamers.find((s) => s.username === 'dreezy');
const dz = body.streamers.find((s) => s.username === 'dreezy');
assert.deepStrictEqual(dz.managed_streams.map((m) => [m.slug, m.title, !!m.last_live_at]), [[null, 'old', true]]);
assert.deepStrictEqual(dz.managed_streams.map((m) => [m.slug, m.title, !!m.last_live_at]), [[null, 'old', true]]);
assert.ok(String(dz.managed_streams[0].vod_thumbnail).endsWith('/t/dreezy-part-2.jpg'), "with its recording's picture");
assert.ok(String(dz.managed_streams[0].vod_thumbnail).endsWith('/t/dreezy-part-2.jpg'), "with its recording's picture");
+ assert.strictEqual(dz.managed_streams[0].vod_id, 9, 'and the recording itself: the row opens its replay (2026-10-27)');
// Next to slots, an account-key stream is listed only while it is what they streamed last: Go Live lists slots,
// Next to slots, an account-key stream is listed only while it is what they streamed last: Go Live lists slots,
// and a months-old one under them read as a slot nobody could find (owner, 2026-10-27).
// and a months-old one under them read as a slot nobody could find (owner, 2026-10-27).
const go = body.streamers.find((s) => s.username === 'goosely');
const go = body.streamers.find((s) => s.username === 'goosely');
@@ -74,3 +75,15 @@ const db = require('../server/db/database');
@@ -74,3 +75,15 @@ const db = require('../server/db/database');
} finally { server.close(); }
} finally { server.close(); }
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+
+// The card (public/js/app-home.js renderRecentlyOnline): an offline row with a recording opens its replay on
+// openvibe.video, with a play mark; a live row, or one with no recording, is the channel (owner, 2026-10-27).
+{
+ const fs = require('fs');
+ const path = require('path');
+ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
+ const body = src.slice(src.indexOf('function renderRecentlyOnline('), src.indexOf('async function loadHomeRecentVods('));
+ assert.ok(body.includes("const replay = !ms.is_live && ms.vod_id ? videoHref('vod', ms.vod_id) : null;"));
+ assert.ok(body.includes('const href = replay || channelPath(s.username, ms.slug || ms.managed_stream_id);'));
+ assert.ok(body.includes('<span class="sg-play" aria-hidden="true">') && body.includes('<span class="sg-replay">Replay</span>'));
+}