openvibers/openvibe.live Commits

Content cards with no picture show who made it (#546)

OP OpenVibers committed 11 days ago · eb3d48803e · 3 files, +60 −5

Changes

ddiff --git a/public/css/features/content.css b/public/css/features/content.css
iindex 95a2305e..d7eb24f2 100644
---- a/public/css/features/content.css
++++ b/public/css/features/content.css
@@@ -502,3 +502,11 @@ a.cf-recap-band:hover .cf-recap-sub { text-decoration: underline; text-underline
.cf-skel-media::after, .cf-skel-line::after { animation: none; }
.cf-media img.cf-thumb, .cf-play { transition: none; }
}
++
++/* A card with no picture shows who made it: their face, round, over a blur of the same picture. */
++.cf-thumb--who { overflow: hidden; }
++.cf-thumb--who .cf-who-bg { position: absolute; inset: -12%; width: 124%; height: 124%; object-fit: cover; filter: blur(22px) saturate(1.2) brightness(.62); }
++.cf-thumb--who .cf-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); }
++.cf-who-letter { display: grid; place-items: center; width: 30%; max-width: 84px; aspect-ratio: 1; border-radius: 50%; font-size: clamp(1.4rem, 7vw, 2.4rem); font-weight: 800;
++ color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent)); box-shadow: 0 0 0 3px color-mix(in srgb, #fff 18%, transparent), 0 8px 24px rgba(0, 0, 0, .35); }
ddiff --git a/public/js/content-feed.js b/public/js/content-feed.js
iindex 3c458fc0..b8f1b320 100644
---- a/public/js/content-feed.js
++++ b/public/js/content-feed.js
@@@ -93,14 +93,36 @@
} else box.textContent = letter;
return box;
}
-- function thumb(url, fallbackIcon) {
-- if (!url) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon));
++ /**
++ * No picture (a recording whose thumbnail was never written, or one that failed): the creator's face over a blur of
++ * it, as Shared's kit.card does (4.89.0), and the plain icon only when there is no face either.
++ */
++ function placeholder(fallbackIcon, who) {
++ const face = who && who.avatar_url;
++ const name = who && String(who.display_name || who.username || '').trim();
++ // No picture of them either: their letter, as their avatar shows it.
++ if (!face && name) {
++ const letter = el('span', { class: 'cf-who-letter', 'aria-hidden': 'true', text: name[0].toUpperCase() });
++ // Their own colour, as the small avatar beside their name has it.
++ if (who.profile_color && /^#[0-9a-f]{3,8}$/i.test(who.profile_color)) letter.style.background = who.profile_color;
++ return el('span', { class: 'cf-thumb cf-thumb--ph cf-thumb--who' }, letter);
++ }
++ if (!face) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon));
++ const src = typeof ovImgW === 'function' ? ovImgW(face, 160) : face;
++ const bg = el('img', { class: 'cf-who-bg', src, alt: '', loading: 'lazy', decoding: 'async' });
++ const me = el('img', { class: 'cf-who-face', src, alt: '', loading: 'lazy', decoding: 'async' });
++ const box = el('span', { class: 'cf-thumb cf-thumb--ph cf-thumb--who' }, bg, me);
++ me.addEventListener('error', () => { box.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon))); });
++ return box;
++ }
++ function thumb(url, fallbackIcon, who) {
++ if (!url) return placeholder(fallbackIcon, who);
// A picture paste is its original upload (one is 2460 px, drawn at 390): Media's 640 px copy, the original once if it
// fails, then the placeholder.
const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
img.addEventListener('error', () => {
if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }
-- img.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon)));
++ img.replaceWith(placeholder(fallbackIcon, who));
});
return img;
}
@@@ -126,7 +148,7 @@
function vodCard(it) {
const media = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` },
-- thumb(it.thumbnail_url, 'fa-video'),
++ thumb(it.thumbnail_url, 'fa-video', it.channel),
el('span', { class: 'cf-kind' }, icon('fa-video'), 'VOD'),
el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }),
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
@@@ -150,7 +172,7 @@
el('button', { type: 'button', class: 'cf-sound', 'aria-label': 'Turn sound on', title: 'Sound' }, icon('fa-volume-xmark')),
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
} else {
-- box = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` }, thumb(it.thumbnail_url, 'fa-scissors'), badge, dur,
++ box = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` }, thumb(it.thumbnail_url, 'fa-scissors', it.channel), badge, dur,
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
}
const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));
ddiff --git a/test/content-card-face.test.js b/test/content-card-face.test.js
nnew file mode 100644
iindex 00000000..9ab484db
---- /dev/null
++++ b/test/content-card-face.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++
++/**
++ * A recording or clip with no picture shows who made it (as Shared's kit.card does since 4.89.0): their face over a blur
++ * of it, else their letter in their own colour, and the plain icon only when there is no one to show.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'content-feed.js'), 'utf8');
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'content.css'), 'utf8');
++const ph = js.slice(js.indexOf('function placeholder('), js.indexOf('function thumb('));
++
++assert.ok(ph.includes("el('img', { class: 'cf-who-bg'") && ph.includes("el('img', { class: 'cf-who-face'"), 'their face over a blur of it');
++assert.ok(ph.includes('ovImgW(face, 160)'), 'at the size it is drawn, not the original upload');
++assert.ok(ph.includes("class: 'cf-who-letter'") && ph.includes('who.profile_color'), 'else their letter, in their own colour');
++assert.ok(/if \(!face\) return el\('span', \{ class: 'cf-thumb cf-thumb--ph' \}, icon\(fallbackIcon\)\);/.test(ph), 'the icon when there is no one');
++assert.ok(ph.includes("me.addEventListener('error'"), 'a face that fails to load falls back to the icon');
++// Recordings and clips pass who made them; a failed thumbnail falls back the same way.
++assert.ok(js.includes("thumb(it.thumbnail_url, 'fa-video', it.channel)") && js.includes("thumb(it.thumbnail_url, 'fa-scissors', it.channel)"), 'VODs and clips pass the creator');
++assert.ok(js.includes('img.replaceWith(placeholder(fallbackIcon, who));'), 'a thumbnail that fails shows the creator too');
++for (const sel of ['.cf-thumb--who .cf-who-bg', '.cf-thumb--who .cf-who-face', '.cf-who-letter']) assert.ok(css.includes(sel), sel);
++
++console.log('content-card-face: all passed');