fix(embeds): animate GIF previews; add Mixcloud/Deezer; misc embed fixes
GIF previews rendered but never played: Synapse's /thumbnail endpoint flattens animated GIFs to a still first frame. GifCard and the generic OG card now request the original via /download (no width/height) for GIFs, so they animate. Guarded with shouldServeGifOriginal(): a matrix:image:size cap (10 MB) keeps a huge self-hosted GIF on the frozen thumbnail, and the generic card's eager <img> gains loading="lazy" (it was the one preview image missing it) so originals stay off the wire until near the viewport. Also adds Mixcloud + Deezer inline media embeds (iframe widgets via parseMediaEmbed/MediaEmbedCard, matching the existing click-to-play pattern), and fixes Deezer podcast links: they live at /show/<id>, not /podcast/<id> (the latter 404s on Deezer's own oEmbed) — verified against the live API. Reviewed by two agents; both findings (Deezer /show, GIF eager-load) fixed and covered by tests. Desktop Tauri frame-src CSP updated separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -549,6 +549,16 @@ export const BadgeTidal = style({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeMixcloud = style({
|
||||||
|
backgroundColor: '#52aad8',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeDeezer = style({
|
||||||
|
backgroundColor: '#a238ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Twitch LIVE badge
|
// Twitch LIVE badge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -305,6 +305,32 @@ function isTenor(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synapse's thumbnailer flattens animated images to a single still frame, so a
|
||||||
|
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
|
||||||
|
// card can point at /download (the original) instead.
|
||||||
|
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (prev['og:image:type'] === 'image/gif') return true;
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname.toLowerCase().endsWith('.gif');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
|
||||||
|
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
|
||||||
|
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
|
||||||
|
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Should this preview's image be fetched whole (so it animates) rather than
|
||||||
|
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
|
||||||
|
// absent we prefer a working animation over a hypothetical huge file.
|
||||||
|
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (!isGifPreview(url, prev)) return false;
|
||||||
|
const size = prev['matrix:image:size'];
|
||||||
|
return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
function getCardVariant(url: string): CardVariant {
|
function getCardVariant(url: string): CardVariant {
|
||||||
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||||
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||||
@@ -1077,6 +1103,8 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
|||||||
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||||
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||||
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||||
|
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
|
||||||
|
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||||
@@ -2081,8 +2109,13 @@ function GifCard({
|
|||||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||||
const mxcImage = prev['og:image'] as string | undefined;
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
|
||||||
|
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||||
|
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||||
|
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
|
||||||
const thumbSrc = mxcImage
|
const thumbSrc = mxcImage
|
||||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
? shouldServeGifOriginal(url, prev)
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||||
|
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// If there's no image, fall back to a generic-style layout
|
// If there's no image, fall back to a generic-style layout
|
||||||
@@ -2180,6 +2213,7 @@ function GenericCard({
|
|||||||
src={displayThumb}
|
src={displayThumb}
|
||||||
alt={prev['og:title']}
|
alt={prev['og:title']}
|
||||||
title={prev['og:title']}
|
title={prev['og:title']}
|
||||||
|
loading="lazy"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||||
onClick={onOpenViewer}
|
onClick={onOpenViewer}
|
||||||
@@ -2349,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// Generic fallback — skip empty cards
|
// Generic fallback — skip empty cards
|
||||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||||
|
|
||||||
const thumbUrl = mxcUrlToHttp(
|
|
||||||
mx,
|
|
||||||
prev['og:image'] || '',
|
|
||||||
useAuthentication,
|
|
||||||
256,
|
|
||||||
256,
|
|
||||||
'scale',
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||||
|
// Show the original for GIFs so they animate; thumbnailing freezes them.
|
||||||
|
const thumbUrl = shouldServeGifOriginal(url, prev)
|
||||||
|
? imgUrl
|
||||||
|
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericCard
|
<GenericCard
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
getBlueskyEmbed,
|
getBlueskyEmbed,
|
||||||
getLoomId,
|
getLoomId,
|
||||||
getKickChannel,
|
getKickChannel,
|
||||||
|
getMixcloudFeed,
|
||||||
|
getDeezerEmbed,
|
||||||
|
deezerEmbedHeight,
|
||||||
getSteamTarget,
|
getSteamTarget,
|
||||||
steamWidgetEmbedUrl,
|
steamWidgetEmbedUrl,
|
||||||
buildVideoEmbedUrl,
|
buildVideoEmbedUrl,
|
||||||
@@ -210,6 +213,58 @@ test('Apple Music: album vs single song height, embed host swap', () => {
|
|||||||
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Mixcloud: cloudcast feed vs profile/section', () => {
|
||||||
|
assert.equal(
|
||||||
|
getMixcloudFeed('https://www.mixcloud.com/NTSRadio/some-show-2024/'),
|
||||||
|
'https://www.mixcloud.com/NTSRadio/some-show-2024/',
|
||||||
|
);
|
||||||
|
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/'), null); // bare profile
|
||||||
|
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/uploads/'), null); // profile tab
|
||||||
|
assert.equal(getMixcloudFeed('https://www.mixcloud.com/discover/house/'), null); // site section
|
||||||
|
assert.equal(getMixcloudFeed('https://example.com/a/b/'), null);
|
||||||
|
assert.ok(
|
||||||
|
parseMediaEmbed('https://www.mixcloud.com/NTSRadio/some-show/', HOST)?.embedUrl.startsWith(
|
||||||
|
'https://www.mixcloud.com/widget/iframe/?feed=',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Deezer: track / album / playlist (+ locale prefix)', () => {
|
||||||
|
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/track/3135556'), {
|
||||||
|
type: 'track',
|
||||||
|
id: '3135556',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getDeezerEmbed('https://deezer.com/en/album/302127'), {
|
||||||
|
type: 'album',
|
||||||
|
id: '302127',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/playlist/1479458365?utm=x'), {
|
||||||
|
type: 'playlist',
|
||||||
|
id: '1479458365',
|
||||||
|
});
|
||||||
|
// Podcasts live at /show/<id>; /podcast/<id> is not a real Deezer path.
|
||||||
|
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/show/1002330852'), {
|
||||||
|
type: 'show',
|
||||||
|
id: '1002330852',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/episode/897651701'), {
|
||||||
|
type: 'episode',
|
||||||
|
id: '897651701',
|
||||||
|
});
|
||||||
|
assert.equal(getDeezerEmbed('https://www.deezer.com/us/podcast/1002330852'), null);
|
||||||
|
assert.equal(getDeezerEmbed('https://www.deezer.com/'), null);
|
||||||
|
assert.equal(getDeezerEmbed('https://www.deezer.com/track/notanid'), null);
|
||||||
|
assert.equal(deezerEmbedHeight('track'), 152);
|
||||||
|
assert.equal(deezerEmbedHeight('episode'), 152);
|
||||||
|
assert.equal(deezerEmbedHeight('show'), 352);
|
||||||
|
assert.equal(deezerEmbedHeight('album'), 352);
|
||||||
|
assert.ok(
|
||||||
|
parseMediaEmbed('https://www.deezer.com/track/3135556', HOST)?.embedUrl.startsWith(
|
||||||
|
'https://widget.deezer.com/widget/dark/track/3135556',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('getSteamTarget: app / news / bundle / non-content', () => {
|
test('getSteamTarget: app / news / bundle / non-content', () => {
|
||||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
||||||
kind: 'app',
|
kind: 'app',
|
||||||
|
|||||||
@@ -555,6 +555,90 @@ export function getBlueskyEmbed(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mixcloud -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Mixcloud's own site sections (first segment) that are never a `<user>`.
|
||||||
|
const MIXCLOUD_RESERVED = new Set([
|
||||||
|
'discover',
|
||||||
|
'categories',
|
||||||
|
'upload',
|
||||||
|
'live',
|
||||||
|
'settings',
|
||||||
|
'notifications',
|
||||||
|
'search',
|
||||||
|
'tag',
|
||||||
|
'select',
|
||||||
|
'browse',
|
||||||
|
]);
|
||||||
|
// Profile tabs — `/<user>/<tab>` is a listing, not a single cloudcast.
|
||||||
|
const MIXCLOUD_PROFILE_TABS = new Set([
|
||||||
|
'uploads',
|
||||||
|
'favorites',
|
||||||
|
'listens',
|
||||||
|
'following',
|
||||||
|
'followers',
|
||||||
|
'playlists',
|
||||||
|
'stream',
|
||||||
|
'reposts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical Mixcloud cloudcast feed URL (`/<user>/<slug>/`) for the widget's
|
||||||
|
* `feed=` param, or null. Bare profiles / profile-tab listings / site sections
|
||||||
|
* are excluded (they aren't a single playable cloudcast).
|
||||||
|
*/
|
||||||
|
export function getMixcloudFeed(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname.replace(/^www\./, '') !== 'mixcloud.com') return null;
|
||||||
|
const parts = u.pathname
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
if (MIXCLOUD_RESERVED.has(parts[0].toLowerCase())) return null;
|
||||||
|
if (MIXCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return null;
|
||||||
|
return `https://www.mixcloud.com/${parts[0]}/${parts[1]}/`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Deezer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// NB: Deezer podcast pages are `/show/<id>`, not `/podcast/<id>` — the latter
|
||||||
|
// 404s on their own oEmbed API, and `widget.deezer.com/widget/dark/show/<id>`
|
||||||
|
// is the matching widget path.
|
||||||
|
const DEEZER_TYPES = ['track', 'album', 'playlist', 'artist', 'show', 'episode'] as const;
|
||||||
|
export type DeezerType = (typeof DEEZER_TYPES)[number];
|
||||||
|
|
||||||
|
/** deezer.com[/<locale>]/<type>/<id> → widget target, or null. */
|
||||||
|
export function getDeezerEmbed(url: string): { type: DeezerType; id: string } | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname.replace(/^www\./, '') !== 'deezer.com') return null;
|
||||||
|
const parts = u.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||||
|
// optional locale prefix (/en/, /us/, /fr/…) then <type>/<id>
|
||||||
|
const idx = parts.findIndex((p) => (DEEZER_TYPES as readonly string[]).includes(p));
|
||||||
|
if (idx === -1 || !parts[idx + 1]) return null;
|
||||||
|
const id = parts[idx + 1].split('?')[0];
|
||||||
|
if (!/^\d+$/.test(id)) return null;
|
||||||
|
return { type: parts[idx] as DeezerType, id };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deezer single track/episode players are compact; collections show a scrollable
|
||||||
|
* tracklist and need room. Mirrors the Spotify sizing (152 / 352) — the widget
|
||||||
|
* requests `tracklist=true`, so 352 keeps the list from being clipped the way a
|
||||||
|
* shorter box would.
|
||||||
|
*/
|
||||||
|
export function deezerEmbedHeight(type: DeezerType): number {
|
||||||
|
return type === 'track' || type === 'episode' ? 152 : 352;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Embed-URL builders ---------------------------------------------------
|
// --- Embed-URL builders ---------------------------------------------------
|
||||||
|
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
@@ -672,6 +756,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
if (tidal)
|
if (tidal)
|
||||||
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
||||||
|
|
||||||
|
const mixFeed = getMixcloudFeed(url);
|
||||||
|
if (mixFeed)
|
||||||
|
return {
|
||||||
|
provider: 'mixcloud',
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://www.mixcloud.com/widget/iframe/?feed=${enc(mixFeed)}&light=0`,
|
||||||
|
height: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deezer = getDeezerEmbed(url);
|
||||||
|
if (deezer)
|
||||||
|
return {
|
||||||
|
provider: 'deezer',
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://widget.deezer.com/widget/dark/${deezer.type}/${enc(
|
||||||
|
deezer.id,
|
||||||
|
)}?app_id=457142&autoplay=false&radius=true&tracklist=true`,
|
||||||
|
height: deezerEmbedHeight(deezer.type),
|
||||||
|
};
|
||||||
|
|
||||||
const insta = getInstagramEmbed(url);
|
const insta = getInstagramEmbed(url);
|
||||||
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user