Compare commits

...

2 Commits

Author SHA1 Message Date
jared 791b1cb5ea feat(embeds): interactive X/Twitter post embed + Apple Music player
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
X/Twitter: the static OG card was a snapshot — no video, no galleries/threads.
Keep it as the (reliable) facade and add a 'View post' button that loads the
official platform.twitter.com interactive embed on click: playable video/GIF,
image galleries, quote tweets. The embed self-sizes via a scoped postMessage
resize listener (matched to our iframe + the platform.twitter.com origin). Nothing
loads from X until the user clicks, and the link still works if X blocks the frame.

Apple Music: music.apple.com album/playlist/song links now play inline via the
embed.music.apple.com player (compact for a single song, tall for collections),
through the existing MediaEmbedCard audio path.

Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds
platform.twitter.com + embed.music.apple.com (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:14:53 -04:00
jared 6c903fe3e5 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>
2026-07-06 20:01:05 -04:00
5 changed files with 563 additions and 181 deletions
@@ -257,6 +257,16 @@ export const EmbedIframe = style([
}, },
]); ]);
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([ export const EmbedPlaceholder = style([
DefaultReset, DefaultReset,
{ {
@@ -271,6 +281,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 +410,26 @@ 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',
});
export const BadgeAppleMusic = style({
backgroundColor: '#fa243c',
color: '#ffffff',
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Twitch LIVE badge // Twitch LIVE badge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+144 -88
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay'; import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -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 { getTweetId, 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({
@@ -573,6 +544,61 @@ function TikTokCard({
// Card 3: Twitter / X // Card 3: Twitter / X
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
// renders the full post — playable video/GIF, image galleries, quote tweets —
// and reports its height back to us via postMessage, which we apply so the card
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
// origin. Only mounted after the user clicks "View post" (privacy facade).
function TweetEmbed({ id }: { id: string }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(320);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.origin !== 'https://platform.twitter.com') return;
if (iframeRef.current && e.source !== iframeRef.current.contentWindow) return;
let payload: unknown = e.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
return;
}
}
const embed = (payload as { 'twttr.embed'?: unknown } | null)?.['twttr.embed'];
const calls = Array.isArray(embed) ? embed : embed ? [embed] : [];
calls.forEach((c: { method?: string; params?: Array<{ height?: number }> }) => {
const h = c?.method === 'twttr.private.resize' ? c.params?.[0]?.height : undefined;
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), 1400));
});
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
return (
<iframe
ref={iframeRef}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
);
}
function TwitterCard({ function TwitterCard({
url, url,
prev, prev,
@@ -584,12 +610,16 @@ function TwitterCard({
mx: ReturnType<typeof useMatrixClient>; mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean; useAuthentication: boolean;
}) { }) {
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [expanded, setExpanded] = useState(false);
const rawTitle = (prev['og:title'] as string | undefined) ?? ''; const rawTitle = (prev['og:title'] as string | undefined) ?? '';
const rawDescription = (prev['og:description'] as string | undefined) ?? ''; const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined; const mxcImage = prev['og:image'] as string | undefined;
const imageWidth = prev['og:image:width'] as number | undefined; const imageWidth = prev['og:image:width'] as number | undefined;
const isTweet = isTwitterTweet(url); const isTweet = isTwitterTweet(url);
const tweetId = getTweetId(url);
const { name, handle, text } = parseTweetTitle(rawTitle); const { name, handle, text } = parseTweetTitle(rawTitle);
const tweetText = text ?? (isTweet ? rawDescription : undefined); const tweetText = text ?? (isTweet ? rawDescription : undefined);
@@ -600,9 +630,9 @@ function TwitterCard({
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false) ? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
: null; : null;
return ( const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
<>
{/* Header row */} const header = (
<div className={previewCss.TwitterHeader}> <div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} /> <XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate> <Text priority="400" style={{ fontWeight: 600 }} truncate>
@@ -616,6 +646,23 @@ function TwitterCard({
<Box grow="Yes" /> <Box grow="Yes" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} /> <SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div> </div>
);
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
if (canEmbed && expanded && tweetId) {
return (
<>
{header}
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
</UrlPreviewContent>
</>
);
}
return (
<>
{header}
<UrlPreviewContent> <UrlPreviewContent>
{isTweet && tweetText ? ( {isTweet && tweetText ? (
@@ -638,6 +685,7 @@ function TwitterCard({
/> />
)} )}
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text <Text
size="T200" size="T200"
priority="300" priority="300"
@@ -649,6 +697,17 @@ function TwitterCard({
> >
{getDomain(url)} {getDomain(url)}
</Text> </Text>
{canEmbed && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
<Text size="T200">View post</Text>
</Chip>
)}
</Box>
</UrlPreviewContent> </UrlPreviewContent>
</> </>
); );
@@ -922,22 +981,31 @@ 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 },
applemusic: { label: 'Apple Music', class: previewCss.BadgeAppleMusic },
};
// 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 +1015,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 +1029,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 +1059,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 +1075,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 +1086,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 +1104,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 +1121,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 +1727,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} />
} }
+113 -36
View File
@@ -5,65 +5,142 @@ import {
getYoutubeShortsId, getYoutubeShortsId,
isYouTubeShorts, isYouTubeShorts,
getVimeoVideoId, getVimeoVideoId,
parseVideoEmbed, getTikTokVideoId,
getDailymotionId,
getStreamableId,
getTwitchTarget,
getSpotifyEmbedTarget,
isSoundCloudTrack,
getAppleMusicEmbed,
getTweetId,
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, });
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(parseVideoEmbed('https://www.youtube.com/watch?v=xyz'), { assert.deepEqual(getTwitchTarget('https://www.twitch.tv/videos/123456789'), {
provider: 'youtube', 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('Apple Music: album vs single song height, embed host swap', () => {
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
assert.equal(album?.height, 450);
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
assert.equal(song?.height, 175);
assert.ok(song?.embedUrl.includes('?i=789'));
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
assert.equal(getTweetId('https://x.com/user'), null);
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
});
test('parseMediaEmbed: Apple Music → audio', () => {
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
assert.equal(m?.provider, 'applemusic');
assert.equal(m?.kind, 'audio');
assert.equal(m?.height, 175);
});
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'));
}); });
+242 -34
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,231 @@ 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 -----------------------------------------------------------
* Build the click-to-play embed URL. YouTube uses the cookie-less
* youtube-nocookie host so nothing is set until the user presses play. export function getStreamableId(url: string): string | null {
*/ try {
export function buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string { const { hostname, pathname } = new URL(url);
const safeId = encodeURIComponent(id); if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
if (provider === 'vimeo') { const m = pathname.match(/^\/([A-Za-z0-9]+)/);
return `https://player.vimeo.com/video/${safeId}?autoplay=1`; return m && m[1] !== 'e' ? m[1] : null;
} catch {
return null;
} }
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`; }
// --- 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;
}
}
// --- Apple Music ----------------------------------------------------------
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
try {
const u = new URL(url);
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
return { embedUrl, height: isSong ? 175 : 450 };
} catch {
return null;
}
}
// --- X / Twitter ----------------------------------------------------------
export function getTweetId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- 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;
}
/**
* Resolve any supported media URL to an embed spec, or null if none applies.
* `host` is the current page hostname — required by Twitch's `parent` param.
*/
export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const shortsId = getYoutubeShortsId(url);
if (shortsId)
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 };
}
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,
};
const apple = getAppleMusicEmbed(url);
if (apple)
return {
provider: 'applemusic',
kind: 'audio',
embedUrl: apple.embedUrl,
height: apple.height,
};
return null;
} }