fix(embeds): animate GIF previews; add Mixcloud/Deezer; misc embed fixes
CI / Build & Quality Checks (push) Successful in 11m21s
CI / Trigger Desktop Build (push) Successful in 34s

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:
Claude
2026-07-24 00:21:10 -04:00
parent a5cc8a6d77
commit 4154cae55a
4 changed files with 208 additions and 10 deletions
+55
View File
@@ -24,6 +24,9 @@ import {
getBlueskyEmbed,
getLoomId,
getKickChannel,
getMixcloudFeed,
getDeezerEmbed,
deezerEmbedHeight,
getSteamTarget,
steamWidgetEmbedUrl,
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);
});
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', () => {
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
kind: 'app',
+104
View File
@@ -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 ---------------------------------------------------
const enc = encodeURIComponent;
@@ -672,6 +756,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
if (tidal)
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);
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };