feat(embeds): add TikTok, Twitch, Dailymotion, Streamable, Spotify, SoundCloud players

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:01:05 -04:00
parent 93f307cd63
commit 6c903fe3e5
5 changed files with 366 additions and 155 deletions
@@ -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 (
<VideoEmbedCard
url={url}
prev={prev}
provider="youtube"
videoId={getYoutubeShortsId(url) ?? ''}
portrait
siteBadgeLabel="Shorts"
siteBadgeClass={previewCss.BadgeYouTubeShorts}
/>
);
}
// ---------------------------------------------------------------------------
// 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<string, { label: string; class: string }> = {
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 ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
@@ -978,12 +970,12 @@ function VideoEmbedCard({
return (
<Box direction="Column">
<div className={mediaClass}>
<div className={mediaClass} style={mediaStyle}>
{playing ? (
<iframe
className={previewCss.EmbedIframe}
src={buildVideoEmbedUrl(provider, videoId)}
title={title || siteBadgeLabel}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allowFullScreen
@@ -994,7 +986,7 @@ function VideoEmbedCard({
type="button"
className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)}
aria-label={`Play: ${title || siteBadgeLabel}`}
aria-label={`Play: ${title || badge.label}`}
>
{thumb}
{playOverlay}
@@ -1005,7 +997,7 @@ function VideoEmbedCard({
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
aria-label={`Open on ${badge.label}: ${title}`}
>
{thumb}
{playOverlay}
@@ -1023,7 +1015,7 @@ function VideoEmbedCard({
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
<SiteBadge label={badge.label} colorClass={badge.class} />
</Text>
{title && (
<Text truncate priority="400">
@@ -1040,32 +1032,6 @@ function VideoEmbedCard({
);
}
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
return (
<VideoEmbedCard
url={url}
prev={prev}
provider="youtube"
videoId={getYouTubeVideoId(url) ?? ''}
siteBadgeLabel="YouTube"
siteBadgeClass=""
/>
);
}
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
return (
<VideoEmbedCard
url={url}
prev={prev}
provider="vimeo"
videoId={getVimeoVideoId(url) ?? ''}
siteBadgeLabel="Vimeo"
siteBadgeClass={previewCss.BadgeVimeo}
/>
);
}
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
@@ -1672,17 +1638,18 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud) gets the media-forward click-to-play tile.
const embed = parseMediaEmbed(url, window.location.hostname);
if (embed) {
return <MediaEmbedCard url={url} prev={prev} embed={embed} />;
}
const variant = getCardVariant(url);
switch (variant) {
case 'youtube-shorts':
return <YouTubeShortsCard url={url} prev={prev} />;
case 'youtube':
return <YouTubeCard url={url} prev={prev} />;
case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'vimeo':
return <VimeoCard url={url} prev={prev} />;
case 'github':
return <GitHubCard url={url} prev={prev} />;
case 'twitter':