openvibers/openvibe.live Commits

Cards play their preview: hover on a computer, the middle card on a phone (#826)

OP OpenVibers committed 2 days ago · 79fb95ec77 · 6 files, +277 −6

Media makes a six-second muted preview beside every recording's and clip's picture, and a live row's preview is its
DVR playlist (ask 355). card-preview.js plays it inside the card's picture:
- a mouse resting on a card (or keyboard focus) plays it; leaving stops it and the video goes
- on a touch screen, the card that settles in the middle after a scroll plays, one at a time
- the picture stays until the video really plays, then it fades in under the badges, with a thin line for how far
  the six seconds have run; live cards use HLS (native in Safari, else the player's hls.js on the first live hover)
- motion Off and Save-Data play nothing; Calm and reduced motion keep hover only; 18+ cards never preview
Cards: home's live and recent streams, a channel's recording in progress, recordings and its three clip grids.
Test: card-preview (rows, refused addresses, listeners, load order, the cards).

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 1723a7e5..40936308 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -816,6 +816,15 @@ body.theme-light .stream-card.is-live::before { opacity: .35; }
- The scrim is tinted with the theme's accent so it belongs to the page, but its base stays
dark: white numerals over a photo need a dark backing whatever palette the reader picked. */
.stream-card-thumb { container-type: inline-size; }
++/* A card's moving preview (card-preview.js, ask 355): the video sits on the picture and fades in once it plays; the
++ badges after it in the markup stay on top. The line along the bottom is how far the six seconds have played. */
++.stream-card-thumb > video.card-preview { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .25s ease; pointer-events: none; }
++.stream-card.is-previewing .stream-card-thumb > video.card-preview { opacity: 1; }
++.stream-card-thumb > .card-preview-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--accent, #3b82f6); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); opacity: 0; transition: opacity .25s ease; pointer-events: none; }
++.stream-card.is-previewing .stream-card-thumb > .card-preview-bar { opacity: .9; }
++.stream-card.is-previewing .stream-card-thumb > img { filter: none; }
++html[data-ov-motion=off] .stream-card-thumb > video.card-preview, html[data-ov-motion=off] .stream-card-thumb > .card-preview-bar { display: none; }
++@media (prefers-reduced-motion: reduce) { .stream-card-thumb > video.card-preview, .stream-card-thumb > .card-preview-bar { transition: none; } }
.stream-card-viewers,
.stream-card-duration {
ddiff --git a/public/features.json b/public/features.json
iindex 96fc574c..7e1b720b 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -39,6 +39,7 @@
"/shared/kit-web.js",
"/js/ov-rail.js",
"/js/ov-live-realtime.js",
++ "/js/card-preview.js",
"/js/app-home.js",
"/js/ov-odometer.js",
"/js/ov-density.js",
@@@ -152,6 +153,7 @@
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-support.js",
++ "/js/card-preview.js",
"/js/app-channel.js",
"/js/bench-stream-watch.js",
"/js/agent-cards.js"
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 52f03403..f2212039 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -336,7 +336,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const pic = ls.thumbnail_url || v.thumbnail_url;
const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';
liveVodHtml += `
-- <a class="stream-card ch-rec-card is-live" data-stream-id="${esc(String(ls.id))}"${bg ? ` style="--live-bg:${esc(bg)}"` : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
++ <a class="stream-card ch-rec-card is-live" data-stream-id="${esc(String(ls.id))}"${typeof previewAttrs === 'function' ? previewAttrs(ls) : ''}${bg ? ` style="--live-bg:${esc(bg)}"` : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
<div class="stream-card-thumb">
${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}
${audioChip(v)}
@@@ -358,7 +358,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
vodsGrid.innerHTML = liveVodHtml + vods.map(v => _selWrap('vod', v.id, `
-- <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
++ <a class="stream-card"${typeof previewAttrs === 'function' ? previewAttrs(v) : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${_visBadge(v.visibility, v.is_public, canManage)}
@@@ -443,7 +443,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
clipsGrid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
-- <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
++ <a class="stream-card"${typeof previewAttrs === 'function' ? previewAttrs(cl) : ''} href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@@ -526,7 +526,7 @@ function _renderClipsTakenGrid(username, data) {
return;
}
grid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
-- <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
++ <a class="stream-card"${typeof previewAttrs === 'function' ? previewAttrs(cl) : ''} href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@@ -573,7 +573,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
if (clips.length) {
grid.innerHTML = clips.map(cl => `
-- <a class="stream-card" href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
++ <a class="stream-card"${typeof previewAttrs === 'function' ? previewAttrs(cl) : ''} href="${videoHref('clip', cl.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', cl.id)}')">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 3b898fd8..0a744952 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1904,7 +1904,7 @@ function streamCardHTML(s, isLive) {
// 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${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)}')">
++ <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 || ''))}"${typeof previewAttrs === 'function' ? previewAttrs(s.is_nsfw ? null : (isLive ? s : { preview_url: s.vod_preview_url, preview_webm_url: s.vod_preview_webm_url })) : ''} 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>' : ''}
ddiff --git a/public/js/card-preview.js b/public/js/card-preview.js
nnew file mode 100644
iindex 00000000..a5e2cda3
---- /dev/null
++++ b/public/js/card-preview.js
@@@ -0,0 +1,195 @@
++/**
++ * card-preview.js — a card's moving preview (ask 355; Opus, 2026-11-11).
++ *
++ * Media makes a 6-second, 320 px, muted preview beside each recording's and clip's thumbnail (preview_url MP4 and
++ * preview_webm_url); a live row's preview_url is Media's DVR playlist of the recording in progress (HLS). A card that
++ * has one carries data-preview (previewAttrs below) and this file plays it inside the card's picture:
++ *
++ * - with a mouse: after a short rest on the card (or keyboard focus on it), until the pointer or focus leaves;
++ * - on a touch screen: the card that settles in the middle of the screen after a scroll, one at a time.
++ *
++ * The picture stays until the video is really playing, then the video fades in over it; badges (LIVE, the length)
++ * stay above it, and a thin line along the bottom shows how far the preview has played. Motion Off and Save-Data play
++ * nothing; Calm and reduced motion keep hover but not the scroll-driven one. Live previews need HLS: Safari plays it
++ * natively, other browsers load hls.js (the player's own copy) on the first live hover.
++ */
++(function () {
++ 'use strict';
++ if (window.cardPreview) return;
++ const FINE = window.matchMedia('(hover: hover) and (pointer: fine)');
++ const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)');
++ const HLS_SRC = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
++ const REST_MS = 260; // a pointer resting this long on a card means "show me"
++ const SETTLE_MS = 420; // a phone's scroll has stopped this long: play the card in the middle
++ const motion = () => document.documentElement.getAttribute('data-ov-motion') || '';
++ const allowed = () => motion() !== 'off' && !(navigator.connection && navigator.connection.saveData);
++ const scrollAllowed = () => allowed() && motion() !== 'calm' && !REDUCED.matches;
++ const safe = (u) => typeof u === 'string' && /^(https:\/\/|\/(?!\/))/.test(u) ? u : '';
++ const attr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
++
++ /** The data attributes for a row's card (empty when it has no preview). */
++ function previewAttrs(row) {
++ const src = safe(row && row.preview_url);
++ if (!src) return '';
++ const webm = safe(row.preview_webm_url);
++ return ` data-preview="${attr(src)}"${webm ? ` data-preview-webm="${attr(webm)}"` : ''}${/\.m3u8(\?|$)/.test(src) ? ' data-preview-hls' : ''}`;
++ }
++
++ let hlsLoading = null;
++ function loadHls() {
++ if (window.Hls) return Promise.resolve(window.Hls);
++ if (!hlsLoading) {
++ hlsLoading = new Promise((resolve, reject) => {
++ const s = document.createElement('script');
++ s.src = HLS_SRC;
++ s.async = true;
++ s.onload = () => (window.Hls ? resolve(window.Hls) : reject(new Error('hls.js')));
++ s.onerror = () => { hlsLoading = null; reject(new Error('hls.js')); };
++ document.head.appendChild(s);
++ });
++ }
++ return hlsLoading;
++ }
++
++ let current = null; // { card, video, bar, hls }
++ let restTimer = 0;
++
++ function stop() {
++ clearTimeout(restTimer);
++ const c = current;
++ current = null;
++ if (!c) return;
++ c.card.classList.remove('is-previewing');
++ try { c.video.pause(); } catch { /* */ }
++ if (c.hls) { try { c.hls.destroy(); } catch { /* */ } }
++ // Let the fade run before the video leaves (the picture is underneath the whole time).
++ setTimeout(() => {
++ c.video.removeAttribute('src');
++ c.video.replaceChildren();
++ c.video.remove();
++ if (c.bar) c.bar.remove();
++ }, 220);
++ }
++
++ function start(card) {
++ if (!card || !card.isConnected || !allowed()) return;
++ if (current && current.card === card) return;
++ stop();
++ const thumb = card.querySelector('.stream-card-thumb');
++ const src = safe(card.dataset.preview);
++ if (!thumb || !src) return;
++ const video = document.createElement('video');
++ video.className = 'card-preview';
++ video.muted = true;
++ video.defaultMuted = true;
++ video.playsInline = true;
++ video.preload = 'auto';
++ video.disablePictureInPicture = true;
++ video.setAttribute('muted', '');
++ video.setAttribute('playsinline', '');
++ video.setAttribute('aria-hidden', 'true');
++ video.tabIndex = -1;
++ const live = card.hasAttribute('data-preview-hls');
++ let bar = null;
++ if (!live) {
++ video.loop = true;
++ bar = document.createElement('span');
++ bar.className = 'card-preview-bar';
++ bar.setAttribute('aria-hidden', 'true');
++ video.addEventListener('timeupdate', () => {
++ if (video.duration) bar.style.setProperty('--p', String(Math.min(1, video.currentTime / video.duration)));
++ });
++ }
++ const entry = { card, video, bar, hls: null };
++ current = entry;
++ video.addEventListener('playing', () => { if (current === entry) card.classList.add('is-previewing'); }, { once: true });
++ video.addEventListener('error', () => { if (current === entry) stop(); }, { once: true });
++ // Under the badges: right after the picture, so LIVE and the length stay on top.
++ const img = thumb.querySelector(':scope > img');
++ thumb.insertBefore(video, img ? img.nextSibling : thumb.firstChild);
++ if (bar) thumb.appendChild(bar);
++ const play = () => { if (current === entry) video.play().catch(() => { /* autoplay refused: the picture stays */ }); };
++ if (live) {
++ if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; play(); return; }
++ loadHls().then((Hls) => {
++ if (current !== entry || !Hls.isSupported()) return;
++ const hls = new Hls({ maxBufferLength: 6, backBufferLength: 0, liveSyncDurationCount: 2, startLevel: 0, capLevelToPlayerSize: true });
++ entry.hls = hls;
++ hls.loadSource(src);
++ hls.attachMedia(video);
++ hls.on(Hls.Events.MANIFEST_PARSED, play);
++ hls.on(Hls.Events.ERROR, (_e, data) => { if (data && data.fatal && current === entry) stop(); });
++ }).catch(() => { if (current === entry) stop(); });
++ return;
++ }
++ const webm = safe(card.dataset.previewWebm);
++ if (webm) { const s = document.createElement('source'); s.src = webm; s.type = 'video/webm'; video.appendChild(s); }
++ const mp4 = document.createElement('source');
++ mp4.src = src;
++ mp4.type = 'video/mp4';
++ video.appendChild(mp4);
++ play();
++ }
++
++ const cardOf = (node) => (node && node.closest ? node.closest('a.stream-card[data-preview]') : null);
++
++ // A mouse: rest on a card to play it, leave to stop.
++ document.addEventListener('pointerover', (e) => {
++ if (e.pointerType !== 'mouse' || !FINE.matches) return;
++ const card = cardOf(e.target);
++ if (!card || (current && current.card === card)) return;
++ clearTimeout(restTimer);
++ restTimer = setTimeout(() => start(card), REST_MS);
++ }, { passive: true });
++ document.addEventListener('pointerout', (e) => {
++ if (e.pointerType !== 'mouse') return;
++ const card = cardOf(e.target);
++ if (!card || card.contains(e.relatedTarget)) return;
++ clearTimeout(restTimer);
++ if (current && current.card === card) stop();
++ }, { passive: true });
++ // The keyboard: focus plays, leaving stops.
++ document.addEventListener('focusin', (e) => {
++ const card = cardOf(e.target);
++ if (card && card === e.target && FINE.matches) { clearTimeout(restTimer); restTimer = setTimeout(() => start(card), REST_MS); }
++ });
++ document.addEventListener('focusout', (e) => {
++ const card = cardOf(e.target);
++ if (card && current && current.card === card && !card.contains(e.relatedTarget)) stop();
++ });
++
++ // A touch screen: the card whose picture sits nearest the middle, fully in view, once the scroll settles.
++ let settleTimer = 0;
++ function settle() {
++ if (FINE.matches || !scrollAllowed() || document.hidden) return;
++ const mid = window.innerHeight / 2;
++ let best = null;
++ let bestD = Infinity;
++ for (const card of document.querySelectorAll('a.stream-card[data-preview]')) {
++ const t = card.querySelector('.stream-card-thumb');
++ if (!t || !t.offsetParent) continue;
++ const r = t.getBoundingClientRect();
++ if (r.top < 0 || r.bottom > window.innerHeight || r.width < 120) continue;
++ const d = Math.abs((r.top + r.bottom) / 2 - mid);
++ if (d < bestD) { bestD = d; best = card; }
++ }
++ if (best) start(best); else stop();
++ }
++ window.addEventListener('scroll', () => {
++ if (FINE.matches) return;
++ clearTimeout(settleTimer);
++ if (current && current.card) {
++ const r = current.card.getBoundingClientRect();
++ if (r.bottom < 0 || r.top > window.innerHeight) stop();
++ }
++ settleTimer = setTimeout(settle, SETTLE_MS);
++ }, { passive: true, capture: true });
++
++ document.addEventListener('visibilitychange', () => { if (document.hidden) stop(); });
++ // A page change (the SPA swaps the grid): whatever was playing goes with it.
++ window.addEventListener('popstate', stop);
++ document.addEventListener('ov:navigate', stop);
++
++ window.cardPreview = { previewAttrs, start, stop, settle };
++ window.previewAttrs = previewAttrs;
++})();
ddiff --git a/test/card-preview.test.js b/test/card-preview.test.js
nnew file mode 100644
iindex 00000000..50ce2d15
---- /dev/null
++++ b/test/card-preview.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// A card's moving preview (public/js/card-preview.js, ask 355): which rows get one, how unsafe addresses are refused,
++// and that the home and channel features load it before the files that render cards.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const ROOT = path.join(__dirname, '..');
++const src = fs.readFileSync(path.join(ROOT, 'public/js/card-preview.js'), 'utf8');
++const listeners = [];
++const win = {
++ matchMedia: () => ({ matches: false }),
++ addEventListener: (t) => listeners.push(`window:${t}`),
++ innerHeight: 800,
++};
++const doc = {
++ documentElement: { getAttribute: () => null },
++ addEventListener: (t) => listeners.push(`document:${t}`),
++ querySelectorAll: () => [],
++ head: { appendChild() {} },
++};
++const ctx = { window: win, document: doc, navigator: {}, setTimeout, clearTimeout };
++win.window = win;
++vm.createContext(ctx);
++vm.runInContext(src, ctx);
++
++const { previewAttrs } = win.cardPreview;
++assert.strictEqual(typeof win.previewAttrs, 'function', 'renderers call the global previewAttrs');
++
++// A recording or clip: the MP4 with the WebM beside it.
++const vod = previewAttrs({ preview_url: 'https://openvibe.media/t/vod-6329-1.mp4', preview_webm_url: 'https://openvibe.media/t/vod-6329-1.webm' });
++assert.strictEqual(vod, ' data-preview="https://openvibe.media/t/vod-6329-1.mp4" data-preview-webm="https://openvibe.media/t/vod-6329-1.webm"');
++// A live row: the DVR playlist, marked as HLS.
++assert.strictEqual(previewAttrs({ preview_url: 'https://openvibe.media/v/6337/dvr.m3u8' }), ' data-preview="https://openvibe.media/v/6337/dvr.m3u8" data-preview-hls');
++// Same-origin paths are fine; nothing, protocol-relative, javascript: and http: are not.
++assert.ok(previewAttrs({ preview_url: '/t/x.mp4' }).includes('data-preview="/t/x.mp4"'));
++for (const bad of [null, '', '//evil.example/x.mp4', 'javascript:alert(1)', 'http://openvibe.media/t/x.mp4']) {
++ assert.strictEqual(previewAttrs({ preview_url: bad }), '', `refused: ${bad}`);
++}
++assert.strictEqual(previewAttrs(null), '');
++// An attribute value cannot break out of its quotes.
++assert.ok(!/"[^"]*"x/.test(previewAttrs({ preview_url: 'https://a.example/"x.mp4' })) && previewAttrs({ preview_url: 'https://a.example/"x.mp4' }).includes('&quot;'));
++// The WebM is only added when it is safe too.
++assert.strictEqual(previewAttrs({ preview_url: 'https://a.example/x.mp4', preview_webm_url: 'javascript:1' }), ' data-preview="https://a.example/x.mp4"');
++
++// It listens for the mouse, the keyboard, the scroll and leaving the page.
++for (const t of ['document:pointerover', 'document:pointerout', 'document:focusin', 'document:focusout', 'window:scroll', 'document:visibilitychange']) {
++ assert.ok(listeners.includes(t), `listens for ${t}`);
++}
++
++// Loaded before the files that render cards.
++const feats = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/features.json'), 'utf8'));
++const F = feats.features || feats;
++for (const [name, renderer] of [['home', '/js/app-home.js'], ['channel', '/js/app-channel.js']]) {
++ const js = F[name].js;
++ assert.ok(js.indexOf('/js/card-preview.js') >= 0 && js.indexOf('/js/card-preview.js') < js.indexOf(renderer), `${name} loads card-preview.js before ${renderer}`);
++}
++// The cards that have previews ask for them.
++const home = fs.readFileSync(path.join(ROOT, 'public/js/app-home.js'), 'utf8');
++assert.ok(/previewAttrs\(s\.is_nsfw \? null : \(isLive \? s :/.test(home), 'home cards: live DVR or the recording; never on an 18+ card');
++const channel = fs.readFileSync(path.join(ROOT, 'public/js/app-channel.js'), 'utf8');
++assert.strictEqual((channel.match(/previewAttrs\(cl\)/g) || []).length, 3, 'every clip grid');
++assert.ok(channel.includes('previewAttrs(v)') && channel.includes('previewAttrs(ls)'), 'recordings and the recording in progress');
++console.log('card-preview: rows, refused addresses, listeners, load order, cards');