openvibers/openvibe.mediahub Commits
Video cards with no picture show who made it (#249)
Changes
@@@ -644,3 +644,10 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i/* A sign-in prompt: the sentence, then the button under it (phone sweep, 2026-11-01). */.notice.signin-prompt { display: grid; gap: 12px; justify-items: start; }.notice.signin-prompt p { margin: 0; }++++/* A card with no picture shows who made it: their face, round, over a blur of the same picture (server/video/pages.js++ cardPlaceholder). */++.ovv-card-who { position: relative; overflow: hidden; }++.ovv-card-who .ovv-who-bg { position: absolute; inset: -12%; width: 124%; height: 124%; object-fit: cover; filter: blur(22px) saturate(1.2) brightness(.62); }++.ovv-card-who .ovv-who-face { position: relative; width: 30%; max-width: 84px; aspect-ratio: 1; height: auto; border-radius: 50%; object-fit: cover;++ box-shadow: 0 0 0 3px color-mix(in srgb, #fff 22%, transparent), 0 8px 24px rgba(0, 0, 0, .45); }@@@ -514,11 +514,27 @@ function viewsLabel(n) {return `${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`;}++/** Network's origin for faces (/avatar/:username answers the picture, or a neutral one): set by createVideoRoutes. */++let faceBase = null;++++/**++ * No picture: the maker's face over a blur of it (as Shared's kit.card and Live's cards show), when the channel is a++ * username; else the kind's icon.++ */++function cardPlaceholder(o, icon) {++ const name = String(o.channel || '');++ if (faceBase && /^[A-Za-z0-9_]{1,32}$/.test(name)) {++ const face = `${faceBase}/avatar/${name}?s=160`;++ return html`<span class="ovv-card-placeholder ovv-card-who"><img class="ovv-who-bg" src="${face}" alt="" loading="lazy" decoding="async"><img class="ovv-who-face" src="${face}" alt="" loading="lazy" decoding="async"></span>`;++ }++ return html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`;++}++function card(o) {const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');return html`<article class="ovv-card">--<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>++<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a><div class="ovv-card-body"><h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}@@@ -572,6 +588,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? htfunction createVideoRoutes(ctx) {const { config, log, playlists } = ctx;++ faceBase = /^https?:\/\//.test(String(config.networkUrl || '')) ? String(config.networkUrl).replace(/\/+$/, '') : null;// A channel page lists that person's public playlists, which are stored by subject: the username is resolved// through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at mostnnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * A recording or clip with no picture shows who made it (Shared's kit.card since 4.89.0, Live #546): the maker's face++ * from Network's /avatar/:username over a blur of it, only for a real username, and the kind's icon otherwise.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.css'), 'utf8');++const fn = src.slice(src.indexOf('function cardPlaceholder('), src.indexOf('function card(o)'));++++assert.ok(fn.includes("/^[A-Za-z0-9_]{1,32}$/.test(name)"), 'only a username becomes an avatar address');++assert.ok(fn.includes('${faceBase}/avatar/${name}?s=160'), 'Network\'s avatar, at the size it is drawn');++assert.ok(fn.includes('class="ovv-who-bg"') && fn.includes('class="ovv-who-face"'), 'the face over a blur of it');++assert.ok(fn.includes('<i class="fa-solid ${icon}"></i>'), 'the icon when there is no username');++assert.ok(src.includes('${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}'), 'cards use it');++assert.ok(/faceBase = \/\^https\?:\\\/\\\/\/\.test\(String\(config\.networkUrl/.test(src), 'the origin comes from the config, and only an http(s) one');++for (const sel of ['.ovv-card-who .ovv-who-bg', '.ovv-card-who .ovv-who-face']) assert.ok(css.includes(sel), sel);++++console.log('card-face: all passed');
@@ -644,3 +644,10 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
@@ -644,3 +644,10 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
/* A sign-in prompt: the sentence, then the button under it (phone sweep, 2026-11-01). */
/* A sign-in prompt: the sentence, then the button under it (phone sweep, 2026-11-01). */
.notice.signin-prompt { display: grid; gap: 12px; justify-items: start; }
.notice.signin-prompt { display: grid; gap: 12px; justify-items: start; }
.notice.signin-prompt p { margin: 0; }
.notice.signin-prompt p { margin: 0; }
+
+/* A card with no picture shows who made it: their face, round, over a blur of the same picture (server/video/pages.js
+ cardPlaceholder). */
+.ovv-card-who { position: relative; overflow: hidden; }
+.ovv-card-who .ovv-who-bg { position: absolute; inset: -12%; width: 124%; height: 124%; object-fit: cover; filter: blur(22px) saturate(1.2) brightness(.62); }
+.ovv-card-who .ovv-who-face { position: relative; width: 30%; max-width: 84px; aspect-ratio: 1; height: auto; border-radius: 50%; object-fit: cover;
+ box-shadow: 0 0 0 3px color-mix(in srgb, #fff 22%, transparent), 0 8px 24px rgba(0, 0, 0, .45); }
@@ -514,11 +514,27 @@ function viewsLabel(n) {
@@ -514,11 +514,27 @@ function viewsLabel(n) {
return `${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`;
return `${v.toLocaleString('en-US')} ${v === 1 ? 'view' : 'views'}`;
}
}
+/** Network's origin for faces (/avatar/:username answers the picture, or a neutral one): set by createVideoRoutes. */
+let faceBase = null;
+
+/**
+ * No picture: the maker's face over a blur of it (as Shared's kit.card and Live's cards show), when the channel is a
+ * username; else the kind's icon.
+ */
+function cardPlaceholder(o, icon) {
+ const name = String(o.channel || '');
+ if (faceBase && /^[A-Za-z0-9_]{1,32}$/.test(name)) {
+ const face = `${faceBase}/avatar/${name}?s=160`;
+ return html`<span class="ovv-card-placeholder ovv-card-who"><img class="ovv-who-bg" src="${face}" alt="" loading="lazy" decoding="async"><img class="ovv-who-face" src="${face}" alt="" loading="lazy" decoding="async"></span>`;
+ }
+ return html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`;
+}
+
function card(o) {
function card(o) {
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');
const icon = o.live ? 'fa-tower-broadcast' : (o.kind === 'clip' ? 'fa-scissors' : 'fa-video');
return html`<article class="ovv-card">
return html`<article class="ovv-card">
-<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : html`<span class="ovv-card-placeholder"><i class="fa-solid ${icon}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>
+<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag${o.live ? ' ovv-tag--live' : ''}">${o.badge}</span>` : ''}</a>
<div class="ovv-card-body">
<div class="ovv-card-body">
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
${meta([channel, o.extra || null, o.views ? viewsLabel(o.views) : null, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
@@ -572,6 +588,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
@@ -572,6 +588,7 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
function createVideoRoutes(ctx) {
function createVideoRoutes(ctx) {
const { config, log, playlists } = ctx;
const { config, log, playlists } = ctx;
+ faceBase = /^https?:\/\//.test(String(config.networkUrl || '')) ? String(config.networkUrl).replace(/\/+$/, '') : null;
// A channel page lists that person's public playlists, which are stored by subject: the username is resolved
// A channel page lists that person's public playlists, which are stored by subject: the username is resolved
// through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most
// through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * A recording or clip with no picture shows who made it (Shared's kit.card since 4.89.0, Live #546): the maker's face
+ * from Network's /avatar/:username over a blur of it, only for a real username, and the kind's icon otherwise.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.css'), 'utf8');
+const fn = src.slice(src.indexOf('function cardPlaceholder('), src.indexOf('function card(o)'));
+
+assert.ok(fn.includes("/^[A-Za-z0-9_]{1,32}$/.test(name)"), 'only a username becomes an avatar address');
+assert.ok(fn.includes('${faceBase}/avatar/${name}?s=160'), 'Network\'s avatar, at the size it is drawn');
+assert.ok(fn.includes('class="ovv-who-bg"') && fn.includes('class="ovv-who-face"'), 'the face over a blur of it');
+assert.ok(fn.includes('<i class="fa-solid ${icon}"></i>'), 'the icon when there is no username');
+assert.ok(src.includes('${o.thumb ? thumbImg(o.thumb, CARD_SIZES) : cardPlaceholder(o, icon)}'), 'cards use it');
+assert.ok(/faceBase = \/\^https\?:\\\/\\\/\/\.test\(String\(config\.networkUrl/.test(src), 'the origin comes from the config, and only an http(s) one');
+for (const sel of ['.ovv-card-who .ovv-who-bg', '.ovv-card-who .ovv-who-face']) assert.ok(css.includes(sel), sel);
+
+console.log('card-face: all passed');