2026-07-06 20:01:05 -04:00
|
|
|
// Pure helpers for detecting embeddable media URLs and building their embed
|
|
|
|
|
// URLs. No React/DOM/CSS imports so this stays unit-testable in isolation.
|
|
|
|
|
//
|
|
|
|
|
// "kind" drives how the card lays the embed out:
|
|
|
|
|
// landscape → 16:9 video portrait → 9:16 video
|
2026-07-06 20:55:47 -04:00
|
|
|
// audio → short fixed-height player (Spotify/SoundCloud/Tidal/Apple Music)
|
|
|
|
|
// rich → tall fixed-height post embed (Instagram / Reddit)
|
2026-07-06 19:48:12 -04:00
|
|
|
|
2026-07-06 20:55:47 -04:00
|
|
|
export type EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
|
2026-07-06 19:48:12 -04:00
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
export type MediaEmbed = {
|
|
|
|
|
provider: string;
|
|
|
|
|
kind: EmbedKind;
|
|
|
|
|
embedUrl: string;
|
|
|
|
|
/** Fixed pixel height for audio/rich embeds (aspect-ratio is used for video). */
|
|
|
|
|
height?: number;
|
2026-07-06 19:48:12 -04:00
|
|
|
};
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- YouTube --------------------------------------------------------------
|
|
|
|
|
|
2026-07-06 23:56:47 -04:00
|
|
|
const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'music.youtube.com'];
|
|
|
|
|
|
2026-07-06 19:48:12 -04:00
|
|
|
export function getYouTubeVideoId(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname, pathname, searchParams } = new URL(url);
|
2026-07-06 20:01:05 -04:00
|
|
|
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
|
2026-07-06 23:56:47 -04:00
|
|
|
if (YOUTUBE_HOSTS.includes(hostname)) {
|
2026-07-06 19:48:12 -04:00
|
|
|
if (pathname === '/watch') return searchParams.get('v');
|
2026-07-06 23:56:47 -04:00
|
|
|
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];
|
2026-07-06 19:48:12 -04:00
|
|
|
}
|
|
|
|
|
} catch {
|
2026-07-06 20:01:05 -04:00
|
|
|
/* ignore */
|
2026-07-06 19:48:12 -04:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isYouTubeShorts(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname, pathname } = new URL(url);
|
2026-07-07 00:08:51 -04:00
|
|
|
if (!YOUTUBE_HOSTS.includes(hostname)) return false;
|
2026-07-06 19:48:12 -04:00
|
|
|
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getYoutubeShortsId(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname, pathname } = new URL(url);
|
2026-07-07 00:08:51 -04:00
|
|
|
if (!YOUTUBE_HOSTS.includes(hostname)) return null;
|
2026-07-06 19:48:12 -04:00
|
|
|
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
|
|
|
|
return m ? m[1] : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- Vimeo ----------------------------------------------------------------
|
|
|
|
|
|
2026-07-06 23:56:47 -04:00
|
|
|
/** Vimeo id + optional private/unlisted hash (vimeo.com/{id}/{hash}). */
|
|
|
|
|
export function getVimeoParts(url: string): { id: string; hash?: string } | null {
|
2026-07-06 19:48:12 -04:00
|
|
|
try {
|
|
|
|
|
const { hostname, pathname } = new URL(url);
|
|
|
|
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
2026-07-07 00:08:51 -04:00
|
|
|
// Canonical /{id} or unlisted /{id}/{hash}
|
|
|
|
|
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
|
|
|
|
if (m) return { id: m[1], hash: m[2] };
|
|
|
|
|
// channels/groups/album share a trailing numeric video id
|
|
|
|
|
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
|
|
|
|
if (m) return { id: m[1] };
|
|
|
|
|
return null;
|
2026-07-06 19:48:12 -04:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 23:56:47 -04:00
|
|
|
export function getVimeoVideoId(url: string): string | null {
|
|
|
|
|
return getVimeoParts(url)?.id ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- TikTok ---------------------------------------------------------------
|
|
|
|
|
|
2026-07-06 23:03:44 -04:00
|
|
|
/** Canonical /video/<id> links only; short links resolve via oEmbed (below). */
|
2026-07-06 20:01:05 -04:00
|
|
|
export function getTikTokVideoId(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname, pathname } = new URL(url);
|
|
|
|
|
const h = hostname.replace(/^www\./, '');
|
|
|
|
|
if (h !== 'tiktok.com') return null;
|
|
|
|
|
const m = pathname.match(/\/video\/(\d+)/);
|
|
|
|
|
return m ? m[1] : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 23:03:44 -04:00
|
|
|
/** Any embeddable TikTok video link — canonical, short (/t/, /v/), or vm/vt.tiktok.com. */
|
|
|
|
|
export function isTikTokLink(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const { hostname, pathname } = new URL(url);
|
|
|
|
|
const h = hostname.replace(/^www\./, '');
|
|
|
|
|
if (h === 'vm.tiktok.com' || h === 'vt.tiktok.com') return true;
|
|
|
|
|
if (h === 'tiktok.com') return /^\/(t\/|v\/|embed\/|@[^/]+\/video\/)/.test(pathname);
|
|
|
|
|
return false;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function tiktokOembedUrl(url: string): string {
|
|
|
|
|
return `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Extract the numeric video id from a TikTok oEmbed JSON response. */
|
|
|
|
|
export function tiktokIdFromOembed(data: {
|
|
|
|
|
embed_product_id?: unknown;
|
|
|
|
|
html?: unknown;
|
|
|
|
|
}): string | null {
|
|
|
|
|
const pid = String(data.embed_product_id ?? '').match(/\d+/)?.[0];
|
|
|
|
|
if (pid) return pid;
|
|
|
|
|
if (typeof data.html === 'string') {
|
|
|
|
|
return data.html.match(/data-video-id="(\d+)"/)?.[1] ?? data.html.match(/\/video\/(\d+)/)?.[1] ?? null;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 00:08:51 -04:00
|
|
|
/**
|
|
|
|
|
* Read a content height out of the postMessage shapes used by the resizable
|
|
|
|
|
* embeds. Shapes verified against the providers' live 2026 embed scripts:
|
|
|
|
|
* Instagram: { type: 'MEASURE', details: { height } }
|
|
|
|
|
* Reddit: { type: 'resize.embed', data: <height> }
|
|
|
|
|
* Twitter/X: { 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height }] }] }
|
|
|
|
|
*/
|
|
|
|
|
export function extractEmbedHeight(data: unknown): number | undefined {
|
|
|
|
|
if (!data || typeof data !== 'object') return undefined;
|
|
|
|
|
const d = data as {
|
|
|
|
|
type?: string;
|
|
|
|
|
height?: number;
|
|
|
|
|
data?: unknown;
|
|
|
|
|
details?: { height?: number };
|
|
|
|
|
'twttr.embed'?: unknown;
|
|
|
|
|
};
|
|
|
|
|
if (d.type === 'MEASURE' && typeof d.details?.height === 'number') return d.details.height;
|
|
|
|
|
if (d.type === 'resize.embed' && typeof d.data === 'number') return d.data;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (typeof d.height === 'number') return d.height;
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 23:03:44 -04:00
|
|
|
export function tiktokPlayerEmbedUrl(id: string): string {
|
2026-07-07 00:32:09 -04:00
|
|
|
// Pure 9:16 video player. music_info/description default OFF (they'd switch
|
|
|
|
|
// TikTok to a wide "video + info panel" layout); controls/progress/play/volume/
|
|
|
|
|
// fullscreen buttons all default ON, so only autoplay + rel are load-bearing.
|
|
|
|
|
return `https://www.tiktok.com/player/v1/${encodeURIComponent(id)}?autoplay=1&rel=0`;
|
2026-07-06 23:03:44 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- 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 */
|
|
|
|
|
}
|
2026-07-06 19:48:12 -04:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- 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);
|
2026-07-07 00:45:51 -04:00
|
|
|
// NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud
|
|
|
|
|
// widget resolver doesn't follow the redirect; supporting them needs an oEmbed
|
|
|
|
|
// round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
|
|
|
|
|
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
|
2026-07-06 20:01:05 -04:00
|
|
|
// /<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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:14:53 -04:00
|
|
|
// --- Apple Music ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
|
2026-07-06 23:56:47 -04:00
|
|
|
export function getAppleMusicEmbed(
|
|
|
|
|
url: string,
|
|
|
|
|
): { embedUrl: string; height: number; video: boolean } | null {
|
2026-07-06 20:14:53 -04:00
|
|
|
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}`;
|
2026-07-06 23:56:47 -04:00
|
|
|
const video = /\/music-video\//.test(u.pathname);
|
2026-07-06 20:14:53 -04:00
|
|
|
// 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);
|
2026-07-06 23:56:47 -04:00
|
|
|
return { embedUrl, height: isSong ? 175 : 450, video };
|
2026-07-06 20:14:53 -04:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:55:47 -04:00
|
|
|
// --- Tidal ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function getTidalEmbed(
|
|
|
|
|
url: string,
|
|
|
|
|
): { kind: 'audio' | 'landscape'; embedUrl: string; height?: number } | null {
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(url);
|
|
|
|
|
const h = u.hostname.replace(/^(www|listen|desktop)\./, '');
|
|
|
|
|
if (h !== 'tidal.com') return null;
|
|
|
|
|
const p = u.pathname.replace(/^\/browse/, '');
|
|
|
|
|
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+)/);
|
2026-07-06 23:56:47 -04:00
|
|
|
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,
|
|
|
|
|
};
|
2026-07-06 20:55:47 -04:00
|
|
|
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
|
|
|
|
|
if (m)
|
2026-07-06 23:56:47 -04:00
|
|
|
return {
|
|
|
|
|
kind: 'audio',
|
|
|
|
|
embedUrl: `https://embed.tidal.com/playlists/${m[1]}?layout=gridify`,
|
|
|
|
|
height: 275,
|
|
|
|
|
};
|
2026-07-06 20:55:47 -04:00
|
|
|
m = p.match(/^\/video\/(\d+)/);
|
|
|
|
|
if (m) return { kind: 'landscape', embedUrl: `https://embed.tidal.com/videos/${m[1]}` };
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Instagram ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function getInstagramEmbed(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(url);
|
|
|
|
|
if (u.hostname !== 'instagram.com' && u.hostname !== 'www.instagram.com') return null;
|
|
|
|
|
const m = u.pathname.match(/^\/(p|reel|reels|tv)\/([A-Za-z0-9_-]+)/);
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
const type = m[1] === 'reels' ? 'reel' : m[1];
|
|
|
|
|
return `https://www.instagram.com/${type}/${m[2]}/embed/`;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Reddit (post embed via redditmedia — bypasses the homeserver's blocked
|
|
|
|
|
// preview fetch, which Reddit serves a bot-check "please wait" page to) ---
|
|
|
|
|
|
2026-07-07 00:32:09 -04:00
|
|
|
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
|
|
|
|
|
|
2026-07-06 20:55:47 -04:00
|
|
|
export function getRedditPostEmbed(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(url);
|
|
|
|
|
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
|
2026-07-07 00:32:09 -04:00
|
|
|
// redd.it/<id> short link → reddit.com/comments/<id> (no subreddit needed).
|
|
|
|
|
if (h === 'redd.it') {
|
|
|
|
|
const id = u.pathname.replace(/^\/+|\/+$/g, '').split('/')[0];
|
|
|
|
|
return id ? `https://embed.reddit.com/comments/${id}/${REDDIT_EMBED_QS}` : null;
|
|
|
|
|
}
|
2026-07-06 20:55:47 -04:00
|
|
|
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;
|
2026-07-06 23:56:47 -04:00
|
|
|
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
|
2026-07-07 00:32:09 -04:00
|
|
|
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
|
2026-07-06 20:55:47 -04:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
// --- Embed-URL builders ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const enc = encodeURIComponent;
|
|
|
|
|
|
2026-07-06 23:56:47 -04:00
|
|
|
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`;
|
2026-07-06 20:01:05 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Spotify compact players (track/episode) are short; collections are taller. */
|
|
|
|
|
export function spotifyEmbedHeight(type: SpotifyType): number {
|
|
|
|
|
return type === 'track' || type === 'episode' ? 152 : 352;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 19:48:12 -04:00
|
|
|
/**
|
2026-07-06 20:01:05 -04:00
|
|
|
* 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.
|
2026-07-06 19:48:12 -04:00
|
|
|
*/
|
2026-07-06 20:01:05 -04:00
|
|
|
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) };
|
|
|
|
|
|
2026-07-06 23:56:47 -04:00
|
|
|
const vimeo = getVimeoParts(url);
|
|
|
|
|
if (vimeo)
|
|
|
|
|
return {
|
|
|
|
|
provider: 'vimeo',
|
|
|
|
|
kind: 'landscape',
|
|
|
|
|
embedUrl: buildVideoEmbedUrl('vimeo', vimeo.id, vimeo.hash),
|
|
|
|
|
};
|
2026-07-06 20:01:05 -04:00
|
|
|
|
2026-07-06 23:03:44 -04:00
|
|
|
// 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
|
|
|
|
|
// resolve the video id, which a sync parser can't do. See isTikTokLink below.
|
2026-07-06 20:01:05 -04:00
|
|
|
|
|
|
|
|
const dmId = getDailymotionId(url);
|
|
|
|
|
if (dmId)
|
|
|
|
|
return {
|
|
|
|
|
provider: 'dailymotion',
|
|
|
|
|
kind: 'landscape',
|
2026-07-06 23:56:47 -04:00
|
|
|
// 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`,
|
2026-07-06 20:01:05 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 };
|
2026-07-06 19:48:12 -04:00
|
|
|
}
|
2026-07-06 20:01:05 -04:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-06 20:14:53 -04:00
|
|
|
const apple = getAppleMusicEmbed(url);
|
|
|
|
|
if (apple)
|
|
|
|
|
return {
|
|
|
|
|
provider: 'applemusic',
|
2026-07-06 23:56:47 -04:00
|
|
|
kind: apple.video ? 'landscape' : 'audio',
|
2026-07-06 20:14:53 -04:00
|
|
|
embedUrl: apple.embedUrl,
|
2026-07-06 23:56:47 -04:00
|
|
|
height: apple.video ? undefined : apple.height,
|
2026-07-06 20:14:53 -04:00
|
|
|
};
|
|
|
|
|
|
2026-07-06 20:55:47 -04:00
|
|
|
const tidal = getTidalEmbed(url);
|
|
|
|
|
if (tidal)
|
|
|
|
|
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
|
|
|
|
|
|
|
|
|
const insta = getInstagramEmbed(url);
|
|
|
|
|
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
|
|
|
|
|
|
|
|
|
const reddit = getRedditPostEmbed(url);
|
|
|
|
|
if (reddit) return { provider: 'reddit', kind: 'rich', embedUrl: reddit, height: 480 };
|
|
|
|
|
|
2026-07-06 20:01:05 -04:00
|
|
|
return null;
|
2026-07-06 19:48:12 -04:00
|
|
|
}
|