openvibers/openvibe.live Commits

Live looks live: a live card wears its own frame, a LIVE pill and the ring (#792)

OP OpenVibers committed 2 days ago · 34222bd637 · 4 files, +87 −6

* Live looks live: a live card wears its own frame, a LIVE pill and the ring (286)

* The labels test reads the card's mark, not its whole opening tag

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 0a446a4b..870f76b2 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -771,12 +771,32 @@ body.ov-nav-overlay .navbar.ovnav-top {
border-radius: var(--radius) var(--radius) 0 0;
}
.stream-card-live {
-- position: absolute; top: 8px; left: 8px;
++ position: absolute; top: 8px; left: 8px; z-index: 2;
++ display: inline-flex; align-items: center; gap: 6px;
background: var(--live-red); color: #fff;
-- padding: 2px 8px; border-radius: var(--radius-sm);
-- font-size: 0.7rem; font-weight: 700;
++ padding: 4px 9px 4px 8px; border-radius: 8px;
++ font-size: 0.7rem; font-weight: 800; letter-spacing: .06em; line-height: 1;
text-transform: uppercase;
--}
++ box-shadow: 0 2px 10px color-mix(in srgb, var(--live-red) 45%, transparent);
++}
++.stream-card-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: ov-live-beat 1.4s ease-in-out infinite; }
++@keyframes ov-live-beat { 50% { opacity: .35; transform: scale(.75); } }
++/* 286, "live looks live": a live card wears its own frame as a soft backdrop under its words (blurred, tinted by the
++ theme's card colour, so text always sits on a scrim), a red hairline and glow, the LIVE pill, and the ring on the
++ face. Hovered on a computer, or centred on a phone (app-home.js _liveCentreWatch), the frame drifts closer and the
++ backdrop wakes. NSFW cards keep no backdrop (app-home.js leaves --live-bg out). */
++.stream-card.is-live { position: relative; isolation: isolate; border-color: color-mix(in srgb, var(--live-red) 38%, var(--border)); box-shadow: var(--shadow-soft), 0 14px 36px -18px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 70%, transparent); }
++.stream-card.is-live::before { content: ""; position: absolute; inset: -20%; z-index: -2; background: var(--live-bg, none) center / cover no-repeat; filter: blur(30px) saturate(1.7); opacity: .6; transition: opacity .5s ease; }
++.stream-card.is-live::after { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(180deg, color-mix(in srgb, var(--bg-card) 18%, transparent) 0%, color-mix(in srgb, var(--bg-card) 52%, transparent) 58%, color-mix(in srgb, var(--bg-card) 70%, transparent) 100%); }
++.stream-card.is-live .stream-card-info { position: relative; background: none; }
++.stream-card.is-live .stream-card-thumb img { transition: transform 7s cubic-bezier(.2, .6, .3, 1), opacity .4s; }
++.stream-card.is-live:hover .stream-card-thumb img, .stream-card.is-live.is-centred .stream-card-thumb img { transform: scale(1.07); }
++.stream-card.is-live:hover::before, .stream-card.is-live.is-centred::before { opacity: .85; }
++.stream-card.is-live:hover { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 18px 44px -16px color-mix(in srgb, var(--thumb-tint, var(--live-red)) 80%, transparent); }
++.stream-card.is-live .stream-card-avatar { box-shadow: 0 0 0 2px var(--live-red), 0 0 0 4px var(--bg-card); }
++html[data-ov-motion=off] .stream-card-live::before { animation: none; }
++html[data-ov-motion=off] .stream-card.is-live .stream-card-thumb img { transition: none; transform: none; }
++body.theme-light .stream-card.is-live::before { opacity: .35; }
.stream-card-thumb .protocol-badge {
position: absolute; bottom: 8px; left: 8px;
}
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 95dd7f95..ad7d09a5 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1896,10 +1896,14 @@ function streamCardHTML(s, isLive) {
const topics = isLive ? _streamTopics(s) : [];
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const shown = topics.filter((t) => t !== cat).slice(0, 2);
++ // 286 ("when they're live … sets the background based on … their thumbnail"): a live card wears its own frame as a
++ // soft backdrop (style.css .stream-card.is-live), a LIVE pill, and the live ring on the face.
++ const liveBg = isLive && !s.is_nsfw ? _liveBg(thumb) : '';
return `
-- <a class="stream-card"${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
++ <a class="stream-card${isLive ? ' is-live' : ''}"${liveBg ? ` style="--live-bg:${esc(liveBg)}"` : ''}${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
++ ${isLive ? '<span class="stream-card-live">Live</span>' : ''}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}
${duration}
@@@ -1958,6 +1962,25 @@ function renderStreamGrid(containerId, streams, isLive) {
const empty = c.querySelector('.empty-state');
c.innerHTML = streams.map(s => streamCardHTML(s, isLive)).join('');
if (empty) { empty.style.display = 'none'; c.appendChild(empty); }
++ if (isLive) _liveCentreWatch(c);
++}
++
++/** A live card's backdrop: its own frame, only from our own addresses (a CSS url() never carries a stranger's). */
++function _liveBg(u) {
++ const v = String(u || '');
++ if (!/^(\/[^/]|https:\/\/([a-z0-9-]+\.)*openvibe\.(live|media|video)\/)/i.test(v) || /["'()\\\s]/.test(v)) return '';
++ return `url("${v}")`;
++}
++
++/**
++ * On a phone (no hover) the live card in the middle of the screen comes alive the way a hovered one does on a computer:
++ * its frame drifts closer and its backdrop glows (style.css .stream-card.is-live.is-centred).
++ */
++let _liveCentreIO = null;
++function _liveCentreWatch(root) {
++ if (!root || typeof IntersectionObserver === 'undefined' || !window.matchMedia || !window.matchMedia('(hover: none)').matches) return;
++ if (!_liveCentreIO) _liveCentreIO = new IntersectionObserver((entries) => entries.forEach((e) => e.target.classList.toggle('is-centred', e.isIntersecting)), { rootMargin: '-38% 0px -38% 0px' });
++ root.querySelectorAll('.stream-card.is-live').forEach((el) => _liveCentreIO.observe(el));
}
/* ── Real-time home updates ─────────────────────────────────── */
@@@ -2022,6 +2045,8 @@ function _updateLiveCard(card, s) {
// Thumbnail: live cards render a placeholder icon (no <img>) until the first frame
// is captured. Crossfade if an <img> exists, else swap the placeholder for one.
if (s.thumbnail_url) {
++ const bg = _liveBg(s.thumbnail_url);
++ if (bg && !s.is_nsfw) card.style.setProperty('--live-bg', bg);
const thumbBox = card.querySelector('.stream-card-thumb');
const img = thumbBox && thumbBox.querySelector('img');
if (img) {
@@@ -2115,6 +2140,7 @@ async function refreshHomeLive() {
if (el) {
el.classList.add('stream-card-appear');
grid.insertBefore(el, grid.children[idx] || null);
++ _liveCentreWatch(el.parentNode);
}
}
});
ddiff --git a/test/labels-view.test.js b/test/labels-view.test.js
iindex 712136d3..bac1af37 100644
---- a/test/labels-view.test.js
++++ b/test/labels-view.test.js
@@@ -25,7 +25,7 @@ ctx.currentUser = { username: 'Carol' };
assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');
const home = read('public/js/app-home.js');
--assert.ok(home.includes('<a class="stream-card"${ovLabelsAttr(s)}'), 'the home card is marked');
++assert.match(home, /<a class="stream-card[^"]*"[^>\n]*\$\{ovLabelsAttr\(s\)\}/, 'the home card is marked (a live one also wears is-live and its frame, ask 286)');
assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');
assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');
const player = read('public/js/stream-player.js');
ddiff --git a/test/live-card.test.js b/test/live-card.test.js
nnew file mode 100644
iindex 00000000..1079fe17
---- /dev/null
++++ b/test/live-card.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// Live looks live (plan ask 286, the owner: "when they're live … sets the background based on … their thumbnail …
++// modern, stand out, unique"): a live card wears its own frame as a soft backdrop, a LIVE pill and the ring; an NSFW card
++// keeps no backdrop; the backdrop is only ever our own address in a CSS url(); a phone's centred card comes alive like a
++// hovered one. The functions are public/js/app-home.js's own, run here.
++// node test/live-card.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
++const body = /function _liveBg\(u\) \{[\s\S]*?\n\}\n/.exec(src);
++assert.ok(body, 'app-home.js has _liveBg');
++const _liveBg = new Function(`${body[0]}; return _liveBg;`)();
++
++assert.strictEqual(_liveBg('/api/thumbnails/live/12.jpg?t=1'), 'url("/api/thumbnails/live/12.jpg?t=1")', 'our own path');
++assert.strictEqual(_liveBg('https://openvibe.media/t/a.jpg'), 'url("https://openvibe.media/t/a.jpg")', 'Media');
++assert.strictEqual(_liveBg('https://cdn.openvibe.video/a.jpg'), 'url("https://cdn.openvibe.video/a.jpg")', 'a subdomain of ours');
++for (const bad of ['https://evil.example/x.jpg', '//evil.example/x.jpg', 'javascript:alert(1)', '/x") ;background:red', '/a b.jpg', "/a'.jpg", 'http://openvibe.live/a.jpg', '', null]) {
++ assert.strictEqual(_liveBg(bad), '', `refused: ${JSON.stringify(bad)}`);
++}
++
++assert.match(src, /const liveBg = isLive && !s\.is_nsfw \? _liveBg\(thumb\) : '';/, 'an NSFW card keeps no backdrop');
++assert.match(src, /<a class="stream-card\$\{isLive \? ' is-live' : ''\}"\$\{liveBg \? ` style="--live-bg:\$\{esc\(liveBg\)\}"` : ''\}/, 'a live card is marked and wears its frame');
++assert.match(src, /\$\{isLive \? '<span class="stream-card-live">Live<\/span>' : ''\}/, 'the LIVE pill');
++assert.match(src, /if \(bg && !s\.is_nsfw\) card\.style\.setProperty\('--live-bg', bg\);/, 'the backdrop follows the newest frame');
++assert.match(src, /if \(isLive\) _liveCentreWatch\(c\);/, 'a phone watches which live card is centred');
++
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
++assert.match(css, /\.stream-card\.is-live::before \{[^}]*background: var\(--live-bg, none\)[^}]*filter: blur\(/, 'the frame, blurred, behind the card');
++assert.match(css, /\.stream-card\.is-live::after \{[^}]*linear-gradient\([^}]*var\(--bg-card\)/, 'a scrim of the card colour, so words stay readable');
++assert.match(css, /\.stream-card\.is-live:hover \.stream-card-thumb img, \.stream-card\.is-live\.is-centred \.stream-card-thumb img \{ transform: scale\(1\.07\); \}/, 'hovered or centred, the frame drifts closer');
++assert.match(css, /\.stream-card\.is-live \.stream-card-avatar \{ box-shadow: 0 0 0 2px var\(--live-red\)/, 'the live ring on the face');
++assert.match(css, /html\[data-ov-motion=off\] \.stream-card\.is-live \.stream-card-thumb img \{ transition: none; transform: none; \}/, 'Motion Off keeps it still');
++console.log('live card: our-own backdrops only, NSFW without one, the pill, the ring, centred on a phone, Motion Off');