feat(embeds): apply review findings + fix TikTok portrait padding
From the review-agent audit:
- Dailymotion: move off the Sept-2024-deprecated /embed/video path to
geo.dailymotion.com/player.html.
- Reddit: point at embed.reddit.com (www.redditmedia.com now 301s there).
- Vimeo: parse the unlisted hash (vimeo.com/{id}/{hash}) and pass &h=…, add dnt=1.
- Tidal: layout=gridify + ~275px height for albums/playlists (fixes narrow player).
- YouTube/Shorts: playsinline=1 (iOS keeps playback inline); parse /live/ +
music.youtube.com.
- Apple Music: /music-video/ renders 16:9 instead of a fixed audio height.
- Re-add a minimal sandbox to all media iframes (omits allow-top-navigation →
blocks phishing redirects) — defense-in-depth atop the CSP frame-src allowlist.
- Self-resize Instagram + Reddit post embeds via a shared useIframeAutoHeight hook
(also now covers the Tweet embed; matches platform.x.com origin too); drop the
fixed 720/480 heights. Cap tweet/post columns at ~550px, centered.
Also from user feedback: TikTok portrait player dropped music_info/description,
which forced TikTok's wide 'video + info panel' layout and left empty space
beside the video — now a clean 9:16 player that fills the box.
Tests 726 pass. CSP frame-src gains embed.reddit.com (separate desktop commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -270,11 +270,14 @@ export const EmbedIframe = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
|
// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
|
||||||
|
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
|
||||||
export const EmbedIframeStatic = style([
|
export const EmbedIframeStatic = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
maxWidth: toRem(550),
|
||||||
|
margin: '0 auto',
|
||||||
border: 0,
|
border: 0,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -553,14 +553,81 @@ function TikTokCard({
|
|||||||
// Card 3: Twitter / X
|
// Card 3: Twitter / X
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
|
// Origins allowed to drive a self-resizing embed (stable refs → effect deps).
|
||||||
// renders the full post — playable video/GIF, image galleries, quote tweets —
|
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
|
||||||
// and reports its height back to us via postMessage, which we apply so the card
|
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
|
||||||
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
|
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
|
||||||
// origin. Only mounted after the user clicks "View post" (privacy facade).
|
const NO_RESIZE_ORIGINS: string[] = [];
|
||||||
|
const EMBED_MAX_HEIGHT = 1400;
|
||||||
|
|
||||||
|
// Defense-in-depth on top of the CSP frame-src allowlist. Critically OMITS
|
||||||
|
// allow-top-navigation, so a compromised embed can't redirect the whole app
|
||||||
|
// (phishing). Fullscreen still works — it's gated by allow=, not a sandbox token.
|
||||||
|
const EMBED_SANDBOX =
|
||||||
|
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation';
|
||||||
|
|
||||||
|
// Pull a content height out of the various providers' postMessage shapes.
|
||||||
|
function extractEmbedHeight(data: unknown): number | undefined {
|
||||||
|
if (!data || typeof data !== 'object') return undefined;
|
||||||
|
const d = data as {
|
||||||
|
type?: string;
|
||||||
|
height?: number;
|
||||||
|
details?: { height?: number };
|
||||||
|
'twttr.embed'?: unknown;
|
||||||
|
};
|
||||||
|
// Instagram: { type: 'MEASURE', details: { height } }
|
||||||
|
if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height;
|
||||||
|
// Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] }
|
||||||
|
const tw = d['twttr.embed'];
|
||||||
|
if (tw) {
|
||||||
|
const calls = (Array.isArray(tw) ? tw : [tw]) as Array<{
|
||||||
|
method?: string;
|
||||||
|
params?: Array<{ height?: number }>;
|
||||||
|
}>;
|
||||||
|
for (let i = 0; i < calls.length; i += 1) {
|
||||||
|
const c = calls[i];
|
||||||
|
if (c?.method === 'twttr.private.resize' && typeof c.params?.[0]?.height === 'number') {
|
||||||
|
return c.params[0].height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reddit / generic: { height }
|
||||||
|
if (typeof d.height === 'number') return d.height;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable-height iframe embeds (Twitter/Instagram/Reddit) report their content
|
||||||
|
// height via postMessage; listen — scoped to the allowed origins + OUR iframe —
|
||||||
|
// and grow to fit. `origins` must be a stable reference.
|
||||||
|
function useIframeAutoHeight(origins: string[], initial: number) {
|
||||||
|
const ref = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const [height, setHeight] = useState(initial);
|
||||||
|
useEffect(() => {
|
||||||
|
if (origins.length === 0) return undefined;
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
if (!origins.includes(e.origin)) return;
|
||||||
|
if (ref.current && e.source !== ref.current.contentWindow) return;
|
||||||
|
let data: unknown = e.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = extractEmbedHeight(data);
|
||||||
|
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), EMBED_MAX_HEIGHT));
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
}, [origins]);
|
||||||
|
return { ref, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
|
||||||
|
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
|
||||||
function TweetEmbed({ id }: { id: string }) {
|
function TweetEmbed({ id }: { id: string }) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
|
||||||
const [height, setHeight] = useState(320);
|
|
||||||
const theme =
|
const theme =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
window.matchMedia &&
|
window.matchMedia &&
|
||||||
@@ -568,32 +635,9 @@ function TweetEmbed({ id }: { id: string }) {
|
|||||||
? 'light'
|
? 'light'
|
||||||
: 'dark';
|
: '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 (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={ref}
|
||||||
className={previewCss.EmbedIframeStatic}
|
className={previewCss.EmbedIframeStatic}
|
||||||
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
|
||||||
id,
|
id,
|
||||||
@@ -1036,7 +1080,18 @@ function MediaEmbedCard({
|
|||||||
const rawDescription = prev['og:description'] ?? '';
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
const portrait = embed.kind === 'portrait';
|
const portrait = embed.kind === 'portrait';
|
||||||
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
|
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
|
||||||
|
const rich = embed.kind === 'rich';
|
||||||
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
|
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
|
||||||
|
// Instagram/Reddit report their height via postMessage → self-resize.
|
||||||
|
const resizeOrigins = !rich
|
||||||
|
? NO_RESIZE_ORIGINS
|
||||||
|
: embed.provider === 'instagram'
|
||||||
|
? INSTAGRAM_ORIGINS
|
||||||
|
: REDDIT_ORIGINS;
|
||||||
|
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
|
||||||
|
resizeOrigins,
|
||||||
|
embed.height ?? 480,
|
||||||
|
);
|
||||||
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
|
||||||
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
|
||||||
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
|
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
|
||||||
@@ -1087,41 +1142,56 @@ function MediaEmbedCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
{playing && rich ? (
|
||||||
{playing ? (
|
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
|
||||||
// No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing
|
<iframe
|
||||||
// breaks player features (fullscreen, TikTok's flow, etc.).
|
ref={resizeRef}
|
||||||
<iframe
|
className={previewCss.EmbedIframeStatic}
|
||||||
className={previewCss.EmbedIframe}
|
src={embed.embedUrl}
|
||||||
src={embed.embedUrl}
|
title={title || badge.label}
|
||||||
title={title || badge.label}
|
style={{ height: `${resizeHeight}px` }}
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
allowFullScreen
|
sandbox={EMBED_SANDBOX}
|
||||||
loading="lazy"
|
allowFullScreen
|
||||||
/>
|
loading="lazy"
|
||||||
) : inlineMediaEmbeds ? (
|
scrolling="no"
|
||||||
<button
|
/>
|
||||||
type="button"
|
) : (
|
||||||
className={previewCss.EmbedFacade}
|
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
|
||||||
onClick={() => setPlaying(true)}
|
{playing ? (
|
||||||
aria-label={`Play: ${title || badge.label}`}
|
<iframe
|
||||||
>
|
className={previewCss.EmbedIframe}
|
||||||
{thumb}
|
src={embed.embedUrl}
|
||||||
{playOverlay}
|
title={title || badge.label}
|
||||||
</button>
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
) : (
|
sandbox={EMBED_SANDBOX}
|
||||||
<a
|
allowFullScreen
|
||||||
href={url}
|
loading="lazy"
|
||||||
target="_blank"
|
/>
|
||||||
rel="noreferrer"
|
) : inlineMediaEmbeds ? (
|
||||||
className={previewCss.EmbedFacade}
|
<button
|
||||||
aria-label={`Open on ${badge.label}: ${title}`}
|
type="button"
|
||||||
>
|
className={previewCss.EmbedFacade}
|
||||||
{thumb}
|
onClick={() => setPlaying(true)}
|
||||||
{playOverlay}
|
aria-label={`Play: ${title || badge.label}`}
|
||||||
</a>
|
>
|
||||||
)}
|
{thumb}
|
||||||
</div>
|
{playOverlay}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.EmbedFacade}
|
||||||
|
aria-label={`Open on ${badge.label}: ${title}`}
|
||||||
|
>
|
||||||
|
{thumb}
|
||||||
|
{playOverlay}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<UrlPreviewContent>
|
<UrlPreviewContent>
|
||||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
<Text
|
<Text
|
||||||
@@ -1229,6 +1299,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
src={tiktokPlayerEmbedUrl(videoId)}
|
src={tiktokPlayerEmbedUrl(videoId)}
|
||||||
title={title || 'TikTok'}
|
title={title || 'TikTok'}
|
||||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getYoutubeShortsId,
|
getYoutubeShortsId,
|
||||||
isYouTubeShorts,
|
isYouTubeShorts,
|
||||||
getVimeoVideoId,
|
getVimeoVideoId,
|
||||||
|
getVimeoParts,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
tiktokIdFromOembed,
|
tiktokIdFromOembed,
|
||||||
@@ -31,14 +32,21 @@ test('YouTube: watch / youtu.be / embed / shorts', () => {
|
|||||||
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_-');
|
||||||
|
assert.equal(getYouTubeVideoId('https://www.youtube.com/live/abcLIVE123'), 'abcLIVE123');
|
||||||
|
assert.equal(getYouTubeVideoId('https://music.youtube.com/watch?v=musicId12'), 'musicId12');
|
||||||
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
|
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
|
||||||
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
||||||
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Vimeo', () => {
|
test('Vimeo (incl. unlisted hash)', () => {
|
||||||
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
|
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
|
||||||
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
|
||||||
|
assert.deepEqual(getVimeoParts('https://vimeo.com/123456789/abc123'), {
|
||||||
|
id: '123456789',
|
||||||
|
hash: 'abc123',
|
||||||
|
});
|
||||||
|
assert.ok(parseMediaEmbed('https://vimeo.com/123456789/abc123', 'h')?.embedUrl.includes('h=abc123'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TikTok: canonical /video/<id> only', () => {
|
test('TikTok: canonical /video/<id> only', () => {
|
||||||
@@ -111,16 +119,23 @@ test('SoundCloud track detection', () => {
|
|||||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
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&playsinline=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('vimeo', '42'),
|
||||||
|
'https://player.vimeo.com/video/42?autoplay=1&dnt=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildVideoEmbedUrl('vimeo', '42', 'h4sh'),
|
||||||
|
'https://player.vimeo.com/video/42?autoplay=1&dnt=1&h=h4sh',
|
||||||
);
|
);
|
||||||
assert.equal(buildVideoEmbedUrl('vimeo', '42'), 'https://player.vimeo.com/video/42?autoplay=1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
|
||||||
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
|
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
|
||||||
provider: 'youtube',
|
provider: 'youtube',
|
||||||
kind: 'portrait',
|
kind: 'portrait',
|
||||||
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0',
|
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0&playsinline=1',
|
||||||
});
|
});
|
||||||
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
|
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
|
||||||
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
|
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
|
||||||
@@ -162,7 +177,8 @@ test('Tidal: track (audio) vs video (landscape)', () => {
|
|||||||
embedUrl: 'https://embed.tidal.com/tracks/12345',
|
embedUrl: 'https://embed.tidal.com/tracks/12345',
|
||||||
height: 120,
|
height: 120,
|
||||||
});
|
});
|
||||||
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl, 'https://embed.tidal.com/albums/999');
|
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.embedUrl, 'https://embed.tidal.com/albums/999?layout=gridify');
|
||||||
|
assert.equal(getTidalEmbed('https://listen.tidal.com/album/999')?.height, 275);
|
||||||
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
|
assert.equal(getTidalEmbed('https://tidal.com/video/555')?.kind, 'landscape');
|
||||||
assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
|
assert.equal(getTidalEmbed('https://tidal.com/browse'), null);
|
||||||
});
|
});
|
||||||
@@ -177,9 +193,9 @@ test('Instagram: p / reel / tv → embed path', () => {
|
|||||||
test('Reddit post embed → redditmedia', () => {
|
test('Reddit post embed → redditmedia', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
|
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
|
||||||
'https://www.redditmedia.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
|
'https://embed.reddit.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
|
||||||
);
|
);
|
||||||
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://www.redditmedia.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
|
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://embed.reddit.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
|
||||||
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
|
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+61
-23
@@ -18,16 +18,19 @@ export type MediaEmbed = {
|
|||||||
|
|
||||||
// --- YouTube --------------------------------------------------------------
|
// --- YouTube --------------------------------------------------------------
|
||||||
|
|
||||||
|
const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'music.youtube.com'];
|
||||||
|
|
||||||
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);
|
||||||
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
|
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
|
||||||
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
if (YOUTUBE_HOSTS.includes(hostname)) {
|
||||||
if (pathname === '/watch') return searchParams.get('v');
|
if (pathname === '/watch') return searchParams.get('v');
|
||||||
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
|
const m =
|
||||||
if (embedMatch) return embedMatch[1];
|
pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/) ||
|
||||||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
pathname.match(/^\/live\/([A-Za-z0-9_-]+)/) ||
|
||||||
if (shortsMatch) return shortsMatch[1];
|
pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||||
|
if (m) return m[1];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -58,17 +61,22 @@ export function getYoutubeShortsId(url: string): string | null {
|
|||||||
|
|
||||||
// --- Vimeo ----------------------------------------------------------------
|
// --- Vimeo ----------------------------------------------------------------
|
||||||
|
|
||||||
export function getVimeoVideoId(url: string): string | null {
|
/** Vimeo id + optional private/unlisted hash (vimeo.com/{id}/{hash}). */
|
||||||
|
export function getVimeoParts(url: string): { id: string; hash?: string } | null {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
const m = pathname.match(/^\/(\d+)/);
|
const m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
||||||
return m ? m[1] : null;
|
return m ? { id: m[1], hash: m[2] } : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVimeoVideoId(url: string): string | null {
|
||||||
|
return getVimeoParts(url)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- TikTok ---------------------------------------------------------------
|
// --- TikTok ---------------------------------------------------------------
|
||||||
|
|
||||||
/** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
|
/** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
|
||||||
@@ -115,9 +123,12 @@ export function tiktokIdFromOembed(data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function tiktokPlayerEmbedUrl(id: string): string {
|
export function tiktokPlayerEmbedUrl(id: string): string {
|
||||||
|
// Pure 9:16 video player. NOTE: music_info/description are intentionally OFF —
|
||||||
|
// they switch TikTok to a wide "video + info panel" layout that leaves empty
|
||||||
|
// space beside the video in a portrait box.
|
||||||
return `https://www.tiktok.com/player/v1/${encodeURIComponent(
|
return `https://www.tiktok.com/player/v1/${encodeURIComponent(
|
||||||
id,
|
id,
|
||||||
)}?autoplay=1&music_info=1&description=1&controls=1&progress_bar=1&play_button=1&volume_control=1&fullscreen_button=1&rel=0`;
|
)}?autoplay=1&controls=1&progress_bar=1&play_button=1&volume_control=1&fullscreen_button=1&rel=0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Dailymotion ----------------------------------------------------------
|
// --- Dailymotion ----------------------------------------------------------
|
||||||
@@ -210,15 +221,18 @@ export function isSoundCloudTrack(url: string): boolean {
|
|||||||
// --- Apple Music ----------------------------------------------------------
|
// --- Apple Music ----------------------------------------------------------
|
||||||
|
|
||||||
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
|
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
|
||||||
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
|
export function getAppleMusicEmbed(
|
||||||
|
url: string,
|
||||||
|
): { embedUrl: string; height: number; video: boolean } | null {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
|
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;
|
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
|
||||||
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
|
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
|
||||||
|
const video = /\/music-video\//.test(u.pathname);
|
||||||
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
|
// 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);
|
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
|
||||||
return { embedUrl, height: isSong ? 175 : 450 };
|
return { embedUrl, height: isSong ? 175 : 450, video };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -251,10 +265,21 @@ export function getTidalEmbed(
|
|||||||
let m = p.match(/^\/track\/(\d+)/);
|
let m = p.match(/^\/track\/(\d+)/);
|
||||||
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
|
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
|
||||||
m = p.match(/^\/album\/(\d+)/);
|
m = p.match(/^\/album\/(\d+)/);
|
||||||
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/albums/${m[1]}`, height: 400 };
|
if (m)
|
||||||
|
// layout=gridify → full-width grid that fills the container (fixes the
|
||||||
|
// narrow/centered default); ~275px is Tidal's own album embed height.
|
||||||
|
return {
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://embed.tidal.com/albums/${m[1]}?layout=gridify`,
|
||||||
|
height: 275,
|
||||||
|
};
|
||||||
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
|
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
|
||||||
if (m)
|
if (m)
|
||||||
return { kind: 'audio', embedUrl: `https://embed.tidal.com/playlists/${m[1]}`, height: 400 };
|
return {
|
||||||
|
kind: 'audio',
|
||||||
|
embedUrl: `https://embed.tidal.com/playlists/${m[1]}?layout=gridify`,
|
||||||
|
height: 275,
|
||||||
|
};
|
||||||
m = p.match(/^\/video\/(\d+)/);
|
m = p.match(/^\/video\/(\d+)/);
|
||||||
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
|
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -288,7 +313,8 @@ export function getRedditPostEmbed(url: string): string | null {
|
|||||||
if (h !== 'reddit.com') return null;
|
if (h !== 'reddit.com') return null;
|
||||||
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return `https://www.redditmedia.com/r/${m[1]}/comments/${m[2]}/?ref_source=embed&ref=share&embed=true&theme=dark`;
|
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
|
||||||
|
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/?ref_source=embed&ref=share&embed=true&theme=dark`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -298,9 +324,15 @@ export function getRedditPostEmbed(url: string): string | null {
|
|||||||
|
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
|
|
||||||
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string): string {
|
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string, hash?: string): string {
|
||||||
if (provider === 'vimeo') return `https://player.vimeo.com/video/${enc(id)}?autoplay=1`;
|
if (provider === 'vimeo') {
|
||||||
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0`;
|
// dnt=1 = Do Not Track (no non-essential cookies); h={hash} required for unlisted.
|
||||||
|
return `https://player.vimeo.com/video/${enc(id)}?autoplay=1&dnt=1${
|
||||||
|
hash ? `&h=${enc(hash)}` : ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
// playsinline=1 keeps iOS Safari from forcing the native fullscreen player.
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0&playsinline=1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spotify compact players (track/episode) are short; collections are taller. */
|
/** Spotify compact players (track/episode) are short; collections are taller. */
|
||||||
@@ -321,9 +353,13 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
if (ytId)
|
if (ytId)
|
||||||
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
|
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
|
||||||
|
|
||||||
const vimeoId = getVimeoVideoId(url);
|
const vimeo = getVimeoParts(url);
|
||||||
if (vimeoId)
|
if (vimeo)
|
||||||
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
|
return {
|
||||||
|
provider: 'vimeo',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: buildVideoEmbedUrl('vimeo', vimeo.id, vimeo.hash),
|
||||||
|
};
|
||||||
|
|
||||||
// NOTE: TikTok is handled by its own card (TikTokEmbedCard) — short "copy-link"
|
// NOTE: TikTok is handled by its own card (TikTokEmbedCard) — short "copy-link"
|
||||||
// URLs (vm.tiktok.com, tiktok.com/t/…) need a client-side oEmbed lookup to
|
// URLs (vm.tiktok.com, tiktok.com/t/…) need a client-side oEmbed lookup to
|
||||||
@@ -334,7 +370,9 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
return {
|
return {
|
||||||
provider: 'dailymotion',
|
provider: 'dailymotion',
|
||||||
kind: 'landscape',
|
kind: 'landscape',
|
||||||
embedUrl: `https://www.dailymotion.com/embed/video/${enc(dmId)}?autoplay=1`,
|
// geo.dailymotion.com is the current player; the old /embed/video path was
|
||||||
|
// deprecated in Sept 2024.
|
||||||
|
embedUrl: `https://geo.dailymotion.com/player.html?video=${enc(dmId)}&autoplay=1`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamableId = getStreamableId(url);
|
const streamableId = getStreamableId(url);
|
||||||
@@ -378,9 +416,9 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
if (apple)
|
if (apple)
|
||||||
return {
|
return {
|
||||||
provider: 'applemusic',
|
provider: 'applemusic',
|
||||||
kind: 'audio',
|
kind: apple.video ? 'landscape' : 'audio',
|
||||||
embedUrl: apple.embedUrl,
|
embedUrl: apple.embedUrl,
|
||||||
height: apple.height,
|
height: apple.video ? undefined : apple.height,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tidal = getTidalEmbed(url);
|
const tidal = getTidalEmbed(url);
|
||||||
|
|||||||
Reference in New Issue
Block a user