openvibers/openvibe.live Commits
Cards play their preview: hover on a computer, the middle card on a phone (#826)
Changes
@@@ -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 staysdark: 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 {@@@ -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"@@@ -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) : ''}@@@ -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>' : ''}nnew file mode 100644@@@ -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, '&').replace(/"/g, '"').replace(/</g, '<');++++ /** 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;++})();nnew file mode 100644@@@ -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('"'));++// 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');
@@ -816,6 +816,15 @@ body.theme-light .stream-card.is-live::before { opacity: .35; }
@@ -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
- 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. */
dark: white numerals over a photo need a dark backing whatever palette the reader picked. */
.stream-card-thumb { container-type: inline-size; }
.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-viewers,
.stream-card-duration {
.stream-card-duration {
@@ -39,6 +39,7 @@
@@ -39,6 +39,7 @@
"/shared/kit-web.js",
"/shared/kit-web.js",
"/js/ov-rail.js",
"/js/ov-rail.js",
"/js/ov-live-realtime.js",
"/js/ov-live-realtime.js",
+ "/js/card-preview.js",
"/js/app-home.js",
"/js/app-home.js",
"/js/ov-odometer.js",
"/js/ov-odometer.js",
"/js/ov-density.js",
"/js/ov-density.js",
@@ -152,6 +153,7 @@
@@ -152,6 +153,7 @@
"/js/channel-socials.js",
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-chat-resize.js",
"/js/channel-support.js",
"/js/channel-support.js",
+ "/js/card-preview.js",
"/js/app-channel.js",
"/js/app-channel.js",
"/js/bench-stream-watch.js",
"/js/bench-stream-watch.js",
"/js/agent-cards.js"
"/js/agent-cards.js"
@@ -336,7 +336,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -336,7 +336,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const pic = ls.thumbnail_url || v.thumbnail_url;
const pic = ls.thumbnail_url || v.thumbnail_url;
const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';
const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';
liveVodHtml += `
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">
<div class="stream-card-thumb">
${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}
${audioChip(v)}
${audioChip(v)}
@@ -358,7 +358,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -358,7 +358,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const canManage = _channelCanManage(username);
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
_selSetContext(canManage, () => _reloadChannelContent(username));
vodsGrid.innerHTML = liveVodHtml + vods.map(v => _selWrap('vod', v.id, `
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">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${_visBadge(v.visibility, v.is_public, canManage)}
${_visBadge(v.visibility, v.is_public, canManage)}
@@ -443,7 +443,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
@@ -443,7 +443,7 @@ function renderChannelClipsSection(username, clips, meta = {}) {
const canManage = _channelCanManage(username);
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => _reloadChannelContent(username));
_selSetContext(canManage, () => _reloadChannelContent(username));
clipsGrid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@ -526,7 +526,7 @@ function _renderClipsTakenGrid(username, data) {
@@ -526,7 +526,7 @@ function _renderClipsTakenGrid(username, data) {
return;
return;
}
}
grid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
@@ -573,7 +573,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
@@ -573,7 +573,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
if (clips.length) {
if (clips.length) {
grid.innerHTML = clips.map(cl => `
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">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
@@ -1904,7 +1904,7 @@ function streamCardHTML(s, isLive) {
@@ -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.
// 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) : '';
const liveBg = isLive && !s.is_nsfw ? _liveBg(thumb) : '';
return `
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>
<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)}
${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>' : ''}
${isLive ? '<span class="stream-card-live">Live</span>' : ''}
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<');
+
+ /** 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;
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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('"'));
+// 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');