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:
@@ -5,6 +5,7 @@ import {
|
||||
getYoutubeShortsId,
|
||||
isYouTubeShorts,
|
||||
getVimeoVideoId,
|
||||
getVimeoParts,
|
||||
getTikTokVideoId,
|
||||
isTikTokLink,
|
||||
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://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
|
||||
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(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
|
||||
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/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', () => {
|
||||
@@ -111,16 +119,23 @@ test('SoundCloud track detection', () => {
|
||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
||||
assert.equal(
|
||||
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', () => {
|
||||
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
|
||||
provider: 'youtube',
|
||||
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://streamable.com/abc', HOST)?.provider, 'streamable');
|
||||
@@ -162,7 +177,8 @@ test('Tidal: track (audio) vs video (landscape)', () => {
|
||||
embedUrl: 'https://embed.tidal.com/tracks/12345',
|
||||
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/browse'), null);
|
||||
});
|
||||
@@ -177,9 +193,9 @@ test('Instagram: p / reel / tv → embed path', () => {
|
||||
test('Reddit post embed → redditmedia', () => {
|
||||
assert.equal(
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
+61
-23
@@ -18,16 +18,19 @@ export type MediaEmbed = {
|
||||
|
||||
// --- YouTube --------------------------------------------------------------
|
||||
|
||||
const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'music.youtube.com'];
|
||||
|
||||
export function getYouTubeVideoId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname, searchParams } = new URL(url);
|
||||
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');
|
||||
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (embedMatch) return embedMatch[1];
|
||||
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||
if (shortsMatch) return shortsMatch[1];
|
||||
const m =
|
||||
pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/) ||
|
||||
pathname.match(/^\/live\/([A-Za-z0-9_-]+)/) ||
|
||||
pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -58,17 +61,22 @@ export function getYoutubeShortsId(url: string): string | null {
|
||||
|
||||
// --- 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 {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||
const m = pathname.match(/^\/(\d+)/);
|
||||
return m ? m[1] : null;
|
||||
const m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
||||
return m ? { id: m[1], hash: m[2] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVimeoVideoId(url: string): string | null {
|
||||
return getVimeoParts(url)?.id ?? null;
|
||||
}
|
||||
|
||||
// --- TikTok ---------------------------------------------------------------
|
||||
|
||||
/** 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 {
|
||||
// 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(
|
||||
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 ----------------------------------------------------------
|
||||
@@ -210,15 +221,18 @@ export function isSoundCloudTrack(url: string): boolean {
|
||||
// --- 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 {
|
||||
export function getAppleMusicEmbed(
|
||||
url: string,
|
||||
): { embedUrl: string; height: number; video: boolean } | 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}`;
|
||||
const video = /\/music-video\//.test(u.pathname);
|
||||
// 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 };
|
||||
return { embedUrl, height: isSong ? 175 : 450, video };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -251,10 +265,21 @@ export function getTidalEmbed(
|
||||
let m = p.match(/^\/track\/(\d+)/);
|
||||
if (m) return { kind: 'audio', embedUrl: `https://embed.tidal.com/tracks/${m[1]}`, height: 120 };
|
||||
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-]+)/);
|
||||
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+)/);
|
||||
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
|
||||
} catch {
|
||||
@@ -288,7 +313,8 @@ export function getRedditPostEmbed(url: string): string | null {
|
||||
if (h !== 'reddit.com') return null;
|
||||
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
@@ -298,9 +324,15 @@ export function getRedditPostEmbed(url: string): string | null {
|
||||
|
||||
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`;
|
||||
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string, hash?: string): string {
|
||||
if (provider === 'vimeo') {
|
||||
// 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. */
|
||||
@@ -321,9 +353,13 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
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 vimeo = getVimeoParts(url);
|
||||
if (vimeo)
|
||||
return {
|
||||
provider: 'vimeo',
|
||||
kind: 'landscape',
|
||||
embedUrl: buildVideoEmbedUrl('vimeo', vimeo.id, vimeo.hash),
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -334,7 +370,9 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
return {
|
||||
provider: 'dailymotion',
|
||||
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);
|
||||
@@ -378,9 +416,9 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
if (apple)
|
||||
return {
|
||||
provider: 'applemusic',
|
||||
kind: 'audio',
|
||||
kind: apple.video ? 'landscape' : 'audio',
|
||||
embedUrl: apple.embedUrl,
|
||||
height: apple.height,
|
||||
height: apple.video ? undefined : apple.height,
|
||||
};
|
||||
|
||||
const tidal = getTidalEmbed(url);
|
||||
|
||||
Reference in New Issue
Block a user