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)

OP OpenVibers committed 17 days ago · 32fa78b561 · 4 files, +45 −5

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 959362e4..100e055e 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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; } }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 43311ef5..8a45e2d2 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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>
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 6736bdf3..967acdb3 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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);
ddiff --git a/test/recently-online-live.test.js b/test/recently-online-live.test.js
iindex 54e976c7..83de213d 100644
---- a/test/recently-online-live.test.js
++++ b/test/recently-online-live.test.js
@@@ -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>'));
++}