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
@@ -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) // Shared square artwork thumbnail (Spotify, IMDb poster, Discord icon)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -389,6 +400,21 @@ export const BadgeTikTok = style({
color: '#ffffff', 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 // Twitch LIVE badge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -19,14 +19,7 @@ import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard'; import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { import { MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed';
buildVideoEmbedUrl,
getVimeoVideoId,
getYouTubeVideoId,
getYoutubeShortsId,
isYouTubeShorts,
VideoEmbedProvider,
} from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -35,10 +28,7 @@ const linkStyles = { color: color.Success.Main };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type CardVariant = type CardVariant =
| 'youtube-shorts'
| 'youtube'
| 'tiktok' | 'tiktok'
| 'vimeo'
| 'github' | 'github'
| 'twitter' | 'twitter'
| 'reddit' | 'reddit'
@@ -283,11 +273,10 @@ function isTenor(url: string): boolean {
} }
function getCardVariant(url: string): CardVariant { function getCardVariant(url: string): CardVariant {
// Shorts must be detected before generic youtube // NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
if (isYouTubeShorts(url)) return 'youtube-shorts'; // upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
if (getYouTubeVideoId(url) !== null) return 'youtube'; // non-embed fallbacks (incl. TikTok short links with no resolvable id).
if (isTikTok(url)) return 'tiktok'; if (isTikTok(url)) return 'tiktok';
if (getVimeoVideoId(url) !== null) return 'vimeo';
if (isGitHubRepo(url)) return 'github'; if (isGitHubRepo(url)) return 'github';
if (isTwitter(url)) return 'twitter'; if (isTwitter(url)) return 'twitter';
if (getRedditSubreddit(url) !== null) return 'reddit'; if (getRedditSubreddit(url) !== null) return 'reddit';
@@ -435,25 +424,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Card 1: YouTube Shorts // Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------
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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TikTokCard({ function TikTokCard({
@@ -922,22 +893,30 @@ function RedditCard({
// in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third // in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
// party until the user presses play. Gated by the `inlineMediaEmbeds` setting — // 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. // 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, url,
prev, prev,
provider, embed,
videoId,
portrait,
siteBadgeLabel,
siteBadgeClass,
}: { }: {
url: string; url: string;
prev: IPreviewUrlResponse; prev: IPreviewUrlResponse;
provider: VideoEmbedProvider; embed: MediaEmbed;
videoId: string;
portrait?: boolean;
siteBadgeLabel: string;
siteBadgeClass: string;
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@@ -947,6 +926,8 @@ function VideoEmbedCard({
const title = prev['og:title'] ?? ''; const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? ''; const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined; const mxcImage = prev['og:image'] as string | undefined;
const portrait = embed.kind === 'portrait';
const audio = embed.kind === 'audio';
const thumbnailUrl = mxcImage const thumbnailUrl = mxcImage
? mxcUrlToHttp( ? mxcUrlToHttp(
mx, mx,
@@ -959,7 +940,18 @@ function VideoEmbedCard({
) )
: undefined; : 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 ? ( const thumb = thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" /> <img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
@@ -978,12 +970,12 @@ function VideoEmbedCard({
return ( return (
<Box direction="Column"> <Box direction="Column">
<div className={mediaClass}> <div className={mediaClass} style={mediaStyle}>
{playing ? ( {playing ? (
<iframe <iframe
className={previewCss.EmbedIframe} className={previewCss.EmbedIframe}
src={buildVideoEmbedUrl(provider, videoId)} src={embed.embedUrl}
title={title || siteBadgeLabel} title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write" allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allowFullScreen allowFullScreen
@@ -994,7 +986,7 @@ function VideoEmbedCard({
type="button" type="button"
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)} onClick={() => setPlaying(true)}
aria-label={`Play: ${title || siteBadgeLabel}`} aria-label={`Play: ${title || badge.label}`}
> >
{thumb} {thumb}
{playOverlay} {playOverlay}
@@ -1005,7 +997,7 @@ function VideoEmbedCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`} aria-label={`Open on ${badge.label}: ${title}`}
> >
{thumb} {thumb}
{playOverlay} {playOverlay}
@@ -1023,7 +1015,7 @@ function VideoEmbedCard({
size="T200" size="T200"
priority="300" priority="300"
> >
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} /> <SiteBadge label={badge.label} colorClass={badge.class} />
</Text> </Text>
{title && ( {title && (
<Text truncate priority="400"> <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 }) { function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? ''; const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? ''; 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; if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => { 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); const variant = getCardVariant(url);
switch (variant) { switch (variant) {
case 'youtube-shorts':
return <YouTubeShortsCard url={url} prev={prev} />;
case 'youtube':
return <YouTubeCard url={url} prev={prev} />;
case 'tiktok': case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />; return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'vimeo':
return <VimeoCard url={url} prev={prev} />;
case 'github': case 'github':
return <GitHubCard url={url} prev={prev} />; return <GitHubCard url={url} prev={prev} />;
case 'twitter': case 'twitter':
@@ -2348,7 +2348,7 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Inline Media Players" title="Inline Media Players"
description="Play YouTube and Vimeo links right in the chat. Nothing loads from the video site until you press play." description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
after={ after={
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} /> <Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
} }
+86 -36
View File
@@ -5,65 +5,115 @@ import {
getYoutubeShortsId, getYoutubeShortsId,
isYouTubeShorts, isYouTubeShorts,
getVimeoVideoId, getVimeoVideoId,
parseVideoEmbed, getTikTokVideoId,
getDailymotionId,
getStreamableId,
getTwitchTarget,
getSpotifyEmbedTarget,
isSoundCloudTrack,
buildVideoEmbedUrl, buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
} from './videoEmbed'; } from './videoEmbed';
test('getYouTubeVideoId: watch / youtu.be / embed / shorts', () => { const HOST = 'chat.lotusguild.org';
test('YouTube: watch / youtu.be / embed / shorts', () => {
assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); assert.equal(getYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ'); assert.equal(getYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ?t=42'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-'); assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
});
test('getYouTubeVideoId: non-YouTube / malformed → null', () => {
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null); assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
assert.equal(getYouTubeVideoId('not a url'), null);
assert.equal(getYouTubeVideoId('https://www.youtube.com/'), null);
});
test('Shorts detection', () => {
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true); assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
assert.equal(isYouTubeShorts('https://www.youtube.com/watch?v=abc123'), false);
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123'); assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
}); });
test('getVimeoVideoId', () => { test('Vimeo', () => {
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789'); assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
assert.equal(getVimeoVideoId('https://vimeo.com/123456789/abcdef'), '123456789');
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null); assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
assert.equal(getVimeoVideoId('https://youtube.com/watch?v=x'), null);
}); });
test('parseVideoEmbed: routes provider + portrait for shorts', () => { test('TikTok: canonical /video/<id> only', () => {
assert.deepEqual(parseVideoEmbed('https://youtube.com/shorts/abc'), { assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
provider: 'youtube', assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link redirects
id: 'abc', assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
portrait: true,
}); });
assert.deepEqual(parseVideoEmbed('https://www.youtube.com/watch?v=xyz'), {
provider: 'youtube', test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://www.dailymotion.com/video/x8abcde'), 'x8abcde');
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
});
test('Twitch: channel / video / clip', () => {
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/somestreamer'), {
type: 'channel',
value: 'somestreamer',
});
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/videos/123456789'), {
type: 'video',
value: '123456789',
});
assert.deepEqual(getTwitchTarget('https://clips.twitch.tv/FunnyClipSlug'), {
type: 'clip',
value: 'FunnyClipSlug',
});
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/streamer/clip/CoolSlug'), {
type: 'clip',
value: 'CoolSlug',
});
});
test('Spotify target + height', () => {
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT'), {
type: 'track',
id: '4cOdK2wGLETKBW3PvgPWqT',
});
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/intl-de/album/xyz'), {
type: 'album',
id: 'xyz', id: 'xyz',
portrait: false,
}); });
assert.deepEqual(parseVideoEmbed('https://vimeo.com/42'), { assert.equal(getSpotifyEmbedTarget('https://open.spotify.com/'), null);
provider: 'vimeo', assert.equal(spotifyEmbedHeight('track'), 152);
id: '42', assert.equal(spotifyEmbedHeight('album'), 352);
portrait: false,
});
assert.equal(parseVideoEmbed('https://example.com/video'), null);
}); });
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo, id encoded', () => { test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
assert.equal( assert.equal(
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'), buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
); );
assert.equal( assert.equal(buildVideoEmbedUrl('vimeo', '42'), 'https://player.vimeo.com/video/42?autoplay=1');
buildVideoEmbedUrl('vimeo', '123456789'), });
'https://player.vimeo.com/video/123456789?autoplay=1',
); test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
// id is URL-encoded (defense-in-depth; ids are normally already safe) assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
assert.ok(buildVideoEmbedUrl('youtube', 'a/b').includes('a%2Fb')); provider: 'youtube',
kind: 'portrait',
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0',
});
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.provider, 'tiktok');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.kind, 'portrait');
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST);
assert.equal(spotify?.kind, 'audio');
assert.equal(spotify?.height, 152);
assert.equal(parseMediaEmbed('https://soundcloud.com/a/b', HOST)?.provider, 'soundcloud');
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
});
test('parseMediaEmbed: Twitch embed carries the parent host', () => {
const chan = parseMediaEmbed('https://twitch.tv/streamer', HOST);
assert.equal(chan?.provider, 'twitch');
assert.ok(chan?.embedUrl.includes('channel=streamer'));
assert.ok(chan?.embedUrl.includes(`parent=${HOST}`));
const clip = parseMediaEmbed('https://clips.twitch.tv/Slug', HOST);
assert.ok(clip?.embedUrl.startsWith('https://clips.twitch.tv/embed?clip=Slug'));
}); });
+200 -32
View File
@@ -1,35 +1,35 @@
// Pure helpers for detecting embeddable video URLs (YouTube, Shorts, Vimeo) and // Pure helpers for detecting embeddable media URLs and building their embed
// building their privacy-friendly embed URLs. No React/DOM/CSS imports so this // URLs. No React/DOM/CSS imports so this stays unit-testable in isolation.
// stays unit-testable in isolation. //
// "kind" drives how the card lays the embed out:
// landscape → 16:9 video portrait → 9:16 video
// audio → short fixed-height player (Spotify/SoundCloud)
export type VideoEmbedProvider = 'youtube' | 'vimeo'; export type EmbedKind = 'landscape' | 'portrait' | 'audio';
export type VideoEmbed = { export type MediaEmbed = {
provider: VideoEmbedProvider; provider: string;
id: string; kind: EmbedKind;
portrait: boolean; // YouTube Shorts render 9:16 embedUrl: string;
/** Fixed pixel height for audio/rich embeds (aspect-ratio is used for video). */
height?: number;
}; };
// --- YouTube --------------------------------------------------------------
export function getYouTubeVideoId(url: string): string | null { export function getYouTubeVideoId(url: string): string | null {
try { try {
const { hostname, pathname, searchParams } = new URL(url); const { hostname, pathname, searchParams } = new URL(url);
// youtu.be/<id> if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') { if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
// youtube.com/watch?v=<id>
if (pathname === '/watch') return searchParams.get('v'); if (pathname === '/watch') return searchParams.get('v');
// youtube.com/embed/<id>
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/); const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
if (embedMatch) return embedMatch[1]; if (embedMatch) return embedMatch[1];
// youtube.com/shorts/<id> (also matched by getYoutubeShortsId)
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/); const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1]; if (shortsMatch) return shortsMatch[1];
} }
} catch { } catch {
// ignore malformed URLs /* ignore */
} }
return null; return null;
} }
@@ -55,6 +55,8 @@ export function getYoutubeShortsId(url: string): string | null {
} }
} }
// --- Vimeo ----------------------------------------------------------------
export function getVimeoVideoId(url: string): string | null { export function getVimeoVideoId(url: string): string | null {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
@@ -66,25 +68,191 @@ export function getVimeoVideoId(url: string): string | null {
} }
} }
/** Detect an embeddable video from any URL, or null if it isn't one. */ // --- TikTok ---------------------------------------------------------------
export function parseVideoEmbed(url: string): VideoEmbed | null {
const shortsId = getYoutubeShortsId(url); /** Only resolvable for canonical /video/<id> links (short vm.tiktok.com links redirect). */
if (shortsId) return { provider: 'youtube', id: shortsId, portrait: true }; export function getTikTokVideoId(url: string): string | null {
const ytId = getYouTubeVideoId(url); try {
if (ytId) return { provider: 'youtube', id: ytId, portrait: false }; const { hostname, pathname } = new URL(url);
const vimeoId = getVimeoVideoId(url); const h = hostname.replace(/^www\./, '');
if (vimeoId) return { provider: 'vimeo', id: vimeoId, portrait: false }; if (h !== 'tiktok.com') return null;
const m = pathname.match(/\/video\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Dailymotion ----------------------------------------------------------
export function getDailymotionId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h === 'dai.ly') return pathname.slice(1).split('/')[0] || null;
if (h === 'dailymotion.com') {
const m = pathname.match(/^\/video\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
}
} catch {
/* ignore */
}
return null; return null;
} }
// --- Streamable -----------------------------------------------------------
export function getStreamableId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
return m && m[1] !== 'e' ? m[1] : null;
} catch {
return null;
}
}
// --- Twitch ---------------------------------------------------------------
export type TwitchTarget =
| { type: 'channel'; value: string }
| { type: 'video'; value: string }
| { type: 'clip'; value: string };
export function getTwitchTarget(url: string): TwitchTarget | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/');
if (h === 'clips.twitch.tv' && parts[0]) return { type: 'clip', value: parts[0] };
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
}
} catch {
/* ignore */
}
return null;
}
// --- Spotify --------------------------------------------------------------
const SPOTIFY_TYPES = ['track', 'album', 'playlist', 'episode', 'show', 'artist'] as const;
export type SpotifyType = (typeof SPOTIFY_TYPES)[number];
export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: string } | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'open.spotify.com') return null;
// /intl-xx/track/<id> or /track/<id>
const parts = pathname.replace(/^\/+/, '').split('/');
const idx = parts.findIndex((p) => (SPOTIFY_TYPES as readonly string[]).includes(p));
if (idx === -1 || !parts[idx + 1]) return null;
return { type: parts[idx] as SpotifyType, id: parts[idx + 1].split('?')[0] };
} catch {
return null;
}
}
// --- SoundCloud -----------------------------------------------------------
export function isSoundCloudTrack(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length >= 2;
} catch {
return false;
}
}
// --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent;
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string): string {
if (provider === 'vimeo') return `https://player.vimeo.com/video/${enc(id)}?autoplay=1`;
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0`;
}
/** Spotify compact players (track/episode) are short; collections are taller. */
export function spotifyEmbedHeight(type: SpotifyType): number {
return type === 'track' || type === 'episode' ? 152 : 352;
}
/** /**
* Build the click-to-play embed URL. YouTube uses the cookie-less * Resolve any supported media URL to an embed spec, or null if none applies.
* youtube-nocookie host so nothing is set until the user presses play. * `host` is the current page hostname — required by Twitch's `parent` param.
*/ */
export function buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string { export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const safeId = encodeURIComponent(id); const shortsId = getYoutubeShortsId(url);
if (provider === 'vimeo') { if (shortsId)
return `https://player.vimeo.com/video/${safeId}?autoplay=1`; return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) };
const ytId = getYouTubeVideoId(url);
if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
const vimeoId = getVimeoVideoId(url);
if (vimeoId)
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
const tiktokId = getTikTokVideoId(url);
if (tiktokId)
return {
provider: 'tiktok',
kind: 'portrait',
embedUrl: `https://www.tiktok.com/player/v1/${enc(tiktokId)}?music_info=1&description=1&rel=0`,
};
const dmId = getDailymotionId(url);
if (dmId)
return {
provider: 'dailymotion',
kind: 'landscape',
embedUrl: `https://www.dailymotion.com/embed/video/${enc(dmId)}?autoplay=1`,
};
const streamableId = getStreamableId(url);
if (streamableId)
return {
provider: 'streamable',
kind: 'landscape',
embedUrl: `https://streamable.com/e/${enc(streamableId)}?autoplay=1`,
};
const twitch = getTwitchTarget(url);
if (twitch) {
const parent = enc(host);
const embedUrl =
twitch.type === 'clip'
? `https://clips.twitch.tv/embed?clip=${enc(twitch.value)}&parent=${parent}&autoplay=true`
: `https://player.twitch.tv/?${twitch.type}=${enc(twitch.value)}&parent=${parent}&autoplay=true`;
return { provider: 'twitch', kind: 'landscape', embedUrl };
} }
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`;
const spotify = getSpotifyEmbedTarget(url);
if (spotify)
return {
provider: 'spotify',
kind: 'audio',
embedUrl: `https://open.spotify.com/embed/${spotify.type}/${enc(spotify.id)}`,
height: spotifyEmbedHeight(spotify.type),
};
if (isSoundCloudTrack(url))
return {
provider: 'soundcloud',
kind: 'audio',
embedUrl: `https://w.soundcloud.com/player/?url=${enc(
url,
)}&color=%23ff5500&auto_play=true&show_comments=false&hide_related=true`,
height: 166,
};
return null;
} }