From 6c903fe3e5b550e539f304f0762164d0f688e7d4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 6 Jul 2026 20:01:05 -0400 Subject: [PATCH] feat(embeds): add TikTok, Twitch, Dailymotion, Streamable, Spotify, SoundCloud players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard renders the media-forward click-to-play facade for all of them — landscape 16:9 (YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok), and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as before: nothing loads from the third party until the user presses play, gated by the 'Inline Media Players' setting. Twitch embeds pass the current page hostname as the required parent param. Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card. All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases). Desktop Tauri CSP frame-src updated for the new hosts (separate commit). Co-Authored-By: Claude Opus 4.8 --- .../components/url-preview/UrlPreview.css.tsx | 26 ++ .../components/url-preview/UrlPreviewCard.tsx | 135 ++++------ src/app/features/settings/general/General.tsx | 2 +- src/app/utils/videoEmbed.test.ts | 122 ++++++--- src/app/utils/videoEmbed.ts | 236 +++++++++++++++--- 5 files changed, 366 insertions(+), 155 deletions(-) diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 480a4eb96..52d6d8b82 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -271,6 +271,17 @@ export const EmbedPlaceholder = style([ }, ]); +// Audio embeds (Spotify / SoundCloud) — full-width, short fixed height (set inline). +export const EmbedMediaAudio = style([ + DefaultReset, + { + position: 'relative', + width: '100%', + overflow: 'hidden', + backgroundColor: color.Surface.Container, + }, +]); + // --------------------------------------------------------------------------- // Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon) // --------------------------------------------------------------------------- @@ -389,6 +400,21 @@ export const BadgeTikTok = style({ color: '#ffffff', }); +export const BadgeSoundCloud = style({ + backgroundColor: '#ff5500', + color: '#ffffff', +}); + +export const BadgeDailymotion = style({ + backgroundColor: '#0066dc', + color: '#ffffff', +}); + +export const BadgeStreamable = style({ + backgroundColor: '#0f90fa', + color: '#ffffff', +}); + // --------------------------------------------------------------------------- // Twitch LIVE badge // --------------------------------------------------------------------------- diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 7ba148479..d1882fa4d 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -19,14 +19,7 @@ import { ImageViewer } from '../image-viewer'; import { onEnterOrSpace } from '../../utils/keyboard'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { - buildVideoEmbedUrl, - getVimeoVideoId, - getYouTubeVideoId, - getYoutubeShortsId, - isYouTubeShorts, - VideoEmbedProvider, -} from '../../utils/videoEmbed'; +import { MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed'; const linkStyles = { color: color.Success.Main }; @@ -35,10 +28,7 @@ const linkStyles = { color: color.Success.Main }; // --------------------------------------------------------------------------- type CardVariant = - | 'youtube-shorts' - | 'youtube' | 'tiktok' - | 'vimeo' | 'github' | 'twitter' | 'reddit' @@ -283,11 +273,10 @@ function isTenor(url: string): boolean { } function getCardVariant(url: string): CardVariant { - // Shorts must be detected before generic youtube - if (isYouTubeShorts(url)) return 'youtube-shorts'; - if (getYouTubeVideoId(url) !== null) return 'youtube'; + // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled + // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the + // non-embed fallbacks (incl. TikTok short links with no resolvable id). if (isTikTok(url)) return 'tiktok'; - if (getVimeoVideoId(url) !== null) return 'vimeo'; if (isGitHubRepo(url)) return 'github'; if (isTwitter(url)) return 'twitter'; if (getRedditSubreddit(url) !== null) return 'reddit'; @@ -435,25 +424,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string }) } // --------------------------------------------------------------------------- -// Card 1: YouTube Shorts -// --------------------------------------------------------------------------- - -function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) { - return ( - - ); -} - -// --------------------------------------------------------------------------- -// Card 2: TikTok +// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id) // --------------------------------------------------------------------------- function TikTokCard({ @@ -922,22 +893,30 @@ function RedditCard({ // in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third // party until the user presses play. Gated by the `inlineMediaEmbeds` setting — // when off it falls back to a link that opens the video in a new tab. -function VideoEmbedCard({ +const EMBED_BADGE: Record = { + youtube: { label: 'YouTube', class: '' }, + vimeo: { label: 'Vimeo', class: previewCss.BadgeVimeo }, + tiktok: { label: 'TikTok', class: previewCss.BadgeTikTok }, + dailymotion: { label: 'Dailymotion', class: previewCss.BadgeDailymotion }, + streamable: { label: 'Streamable', class: previewCss.BadgeStreamable }, + twitch: { label: 'Twitch', class: previewCss.BadgeTwitchPurple }, + spotify: { label: 'Spotify', class: previewCss.BadgeSpotify }, + soundcloud: { label: 'SoundCloud', class: previewCss.BadgeSoundCloud }, +}; + +// One media-forward tile for every embeddable provider (video / portrait / audio). +// Privacy-friendly facade: shows the homeserver's cached thumbnail + a play +// button; only on click does it swap in the provider iframe, so nothing loads +// from the third party until the user presses play. Gated by `inlineMediaEmbeds` +// — when off it falls back to a link that opens the media in a new tab. +function MediaEmbedCard({ url, prev, - provider, - videoId, - portrait, - siteBadgeLabel, - siteBadgeClass, + embed, }: { url: string; prev: IPreviewUrlResponse; - provider: VideoEmbedProvider; - videoId: string; - portrait?: boolean; - siteBadgeLabel: string; - siteBadgeClass: string; + embed: MediaEmbed; }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -947,6 +926,8 @@ function VideoEmbedCard({ const title = prev['og:title'] ?? ''; const description = prev['og:description'] ?? ''; const mxcImage = prev['og:image'] as string | undefined; + const portrait = embed.kind === 'portrait'; + const audio = embed.kind === 'audio'; const thumbnailUrl = mxcImage ? mxcUrlToHttp( mx, @@ -959,7 +940,18 @@ function VideoEmbedCard({ ) : undefined; - const mediaClass = portrait ? previewCss.EmbedMediaPortrait : previewCss.EmbedMediaLandscape; + // Shorts share the youtube provider but get their own badge. + const badge = + embed.provider === 'youtube' && portrait + ? { label: 'Shorts', class: previewCss.BadgeYouTubeShorts } + : (EMBED_BADGE[embed.provider] ?? { label: embed.provider, class: '' }); + + const mediaClass = audio + ? previewCss.EmbedMediaAudio + : portrait + ? previewCss.EmbedMediaPortrait + : previewCss.EmbedMediaLandscape; + const mediaStyle = audio ? { height: `${embed.height ?? 152}px` } : undefined; const thumb = thumbnailUrl ? ( {title} @@ -978,12 +970,12 @@ function VideoEmbedCard({ return ( -
+
{playing ? (