openvibers/openvibe.live Commits
Content cards with no picture show who made it (#546)
Changes
@@@ -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); }@@@ -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));nnew file mode 100644@@@ -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');
@@ -502,3 +502,11 @@ a.cf-recap-band:hover .cf-recap-sub { text-decoration: underline; text-underline
@@ -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-skel-media::after, .cf-skel-line::after { animation: none; }
.cf-media img.cf-thumb, .cf-play { transition: 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); }
@@ -93,14 +93,36 @@
@@ -93,14 +93,36 @@
} else box.textContent = letter;
} else box.textContent = letter;
return box;
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
// 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.
// fails, then the placeholder.
const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
const img = el('img', { class: 'cf-thumb', src: typeof ovImgW === 'function' ? ovImgW(url, 640) : url, alt: '', loading: 'lazy', decoding: 'async' });
img.addEventListener('error', () => {
img.addEventListener('error', () => {
if (img.src !== url && !img.dataset.retried) { img.dataset.retried = '1'; img.src = url; return; }
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;
return img;
}
}
@@ -126,7 +148,7 @@
@@ -126,7 +148,7 @@
function vodCard(it) {
function vodCard(it) {
const media = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` },
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-kind' }, icon('fa-video'), 'VOD'),
el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }),
el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }),
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
@@ -150,7 +172,7 @@
@@ -150,7 +172,7 @@
el('button', { type: 'button', class: 'cf-sound', 'aria-label': 'Turn sound on', title: 'Sound' }, icon('fa-volume-xmark')),
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')));
el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
} else {
} 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')));
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));
const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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');