From 4154cae55a611e97cb2a4416abbb0230af08b141 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 00:21:10 -0400 Subject: [PATCH] fix(embeds): animate GIF previews; add Mixcloud/Deezer; misc embed fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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/, not /podcast/ (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 --- .../components/url-preview/UrlPreview.css.tsx | 10 ++ .../components/url-preview/UrlPreviewCard.tsx | 49 +++++++-- src/app/utils/videoEmbed.test.ts | 55 +++++++++ src/app/utils/videoEmbed.ts | 104 ++++++++++++++++++ 4 files changed, 208 insertions(+), 10 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 89e5660c1..a62d7c9f9 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -549,6 +549,16 @@ export const BadgeTidal = style({ color: '#ffffff', }); +export const BadgeMixcloud = style({ + backgroundColor: '#52aad8', + color: '#ffffff', +}); + +export const BadgeDeezer = style({ + backgroundColor: '#a238ff', + color: '#ffffff', +}); + // --------------------------------------------------------------------------- // Twitch LIVE badge // --------------------------------------------------------------------------- diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 754668da6..c037efb0e 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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 { // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the @@ -1077,6 +1103,8 @@ const EMBED_BADGE: Record = { bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky }, loom: { label: 'Loom', class: previewCss.BadgeLoom }, 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 @@ -2081,8 +2109,13 @@ function GifCard({ const title = (prev['og:title'] 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 - ? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false) + ? shouldServeGifOriginal(url, prev) + ? mxcUrlToHttp(mx, mxcImage, useAuthentication) + : mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false) : null; // If there's no image, fall back to a generic-style layout @@ -2180,6 +2213,7 @@ function GenericCard({ src={displayThumb} alt={prev['og:title']} title={prev['og:title']} + loading="lazy" tabIndex={0} onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)} onClick={onOpenViewer} @@ -2349,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( // Generic fallback — skip empty cards 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); + // 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 ( { 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/; /podcast/ 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', diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index 287920cca..1999ab64a 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -555,6 +555,90 @@ export function getBlueskyEmbed(url: string): string | null { } } +// --- Mixcloud ------------------------------------------------------------- + +// Mixcloud's own site sections (first segment) that are never a ``. +const MIXCLOUD_RESERVED = new Set([ + 'discover', + 'categories', + 'upload', + 'live', + 'settings', + 'notifications', + 'search', + 'tag', + 'select', + 'browse', +]); +// Profile tabs — `//` 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 (`///`) 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/`, not `/podcast/` — the latter +// 404s on their own oEmbed API, and `widget.deezer.com/widget/dark/show/` +// 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[/]// → 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 / + 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 };