Compare commits

..

10 Commits

Author SHA1 Message Date
jared 013f113bc2 feat(embeds): add Bluesky, Loom, Kick
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 9s
From the coverage-review agent's ranked recommendations (clean iframes, ids in
the URL, one CSP host each):
- Bluesky: bsky.app/profile/{authority}/post/{rkey} → embed.bsky.app (rich, self-
  resizing like the other post embeds).
- Loom: loom.com/share|embed/{id} → www.loom.com/embed/{id} (16:9).
- Kick: kick.com/{channel} → player.kick.com/{channel} (live channels only; VODs/
  clips have no clean embed and fall back to a link).

Parsers unit-tested. CSP frame-src gains embed.bsky.app / www.loom.com /
player.kick.com (desktop + live web, both updated). Needs live verification once
deployed since the embeds themselves can't be exercised from here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:50:39 -04:00
jared a52c9e12a4 fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert
From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
  redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
  expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
  keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
  a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
  'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
  dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.

Tests 728. No CSP change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:45:51 -04:00
jared a28c305835 feat(embeds): TikTok URL trim, SoundCloud on. + redd.it short links
- TikTok player URL trimmed to ?autoplay=1&rel=0 (the control params were all
  default-on no-ops).
- SoundCloud on.soundcloud.com share short links now embed (widget follows the
  redirect via w.soundcloud.com).
- redd.it short links now embed via embed.reddit.com/comments/<id>/ (the redirect
  target is reddit.com/comments/<id>, no subreddit needed).

No CSP change (hosts already allowlisted). Vimeo event/ondemand deferred — the
embed format/host couldn't be verified from a fake id and would need a CSP
change; leaving it for the review agents to research. Tests 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:32:09 -04:00
jared 3694a3612d fix(embeds): second-pass review — Reddit auto-height, sandbox, Vimeo/Shorts gaps
From the second review pass (agents inspected providers' live embed scripts):
- FIX Reddit auto-height (was broken): embed.reddit.com posts
  { type:'resize.embed', data:<height> } — height is under 'data', not 'height',
  so it never resized and clipped taller posts (scrolling=no). Add that shape.
- Align TweetEmbed sandbox with EMBED_SANDBOX (adds allow-popups-to-escape-sandbox)
  so links/login popups opened from inside a tweet aren't crippled.
- Vimeo: resolve channel/group/album video forms (vimeo.com/channels/{n}/{id} etc.),
  not just paths starting with the id.
- Mobile Shorts: m.youtube.com/shorts/{id} now renders portrait 9:16, not landscape.
- Move extractEmbedHeight into videoEmbed.ts and unit-test all three resize shapes
  (Instagram MEASURE / Reddit resize.embed / Twitter twttr.private.resize).

Agents confirmed everything else current & robust (Dailymotion geo host, Tidal
gridify, sandbox tokens are a safe superset, X still on platform.twitter.com,
550px cap). Tests 21 in this suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:08:51 -04:00
jared 4de2d233ef 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>
2026-07-06 23:56:47 -04:00
jared b247a0447c fix(embeds): resolve TikTok short links via oEmbed + og:url fallback
CI / Build & Quality Checks (push) Successful in 10m51s
CI / Trigger Desktop Build (push) Successful in 9s
TikTok 'copy-link' share URLs (vm.tiktok.com, tiktok.com/t/…) carry no video id
and the homeserver's link preview is bot-walled (generic 'TikTok - Make Your
Day', no og:url/og:image), so they fell through to the static fallback card with
no play button.

New TikTokEmbedCard resolves the id client-side via TikTok's CORS-enabled oEmbed
API on click (keeps the facade privacy model), then plays the player/v1 embed
(portrait, autoplay + full controls + our fullscreen button). Canonical
/video/<id> links skip the lookup. Also added a general og:url fallback so other
short/redirect links resolve to their canonical form when the raw URL doesn't.

Web CSP connect-src gains www.tiktok.com for the oEmbed fetch (desktop already
allows https:). Tests 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:03:44 -04:00
jared 6f15f7f56e fix(embeds): wider card for Tidal, declutter caption while video plays
- Widen the interactive-embed card to min(38rem, 94vw) so the Tidal player (and
  other audio/video embeds) has enough width.
- Hide the title/description caption while a video embed is playing, so the text
  below it stops squeezing the player small (Twitch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:23:32 -04:00
jared fbaa921f83 feat(embeds): fullscreen, wider cards, Instagram/Tidal/Reddit; fix TikTok/X/Twitch
CI / Build & Quality Checks (push) Successful in 10m35s
CI / Trigger Desktop Build (push) Successful in 8s
Feedback fixes + new platforms:
- Fullscreen: add a universal 'Fullscreen' button on video embeds (requests
  fullscreen on the media container) so Shorts/TikTok/etc. can go fullscreen
  regardless of each player's own controls. Portrait media enlarged (220->300).
- Drop the iframe sandbox (CSP-allowlisted trusted hosts; sandbox was breaking
  player features and likely TikTok).
- Wider, responsive embed/tweet cards (min(34rem,92vw)) so Twitch player chrome
  isn't cramped and X posts stop getting clipped.
- Instagram (p/reel/tv), Tidal (track/album/playlist/video), and Reddit posts
  now embed. Reddit uses redditmedia.com to bypass the homeserver's blocked
  preview (Reddit serves it a bot-check 'please wait for verification' page);
  a bot-wall title filter keeps that garbage out of any caption.

New pure parsers unit-tested (videoEmbed.test.ts, 17 cases). Desktop + live web
CSP frame-src updated for instagram.com, embed.tidal.com, redditmedia.com.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:55:47 -04:00
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
6 changed files with 1323 additions and 226 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ export function RenderMessageContent({
eventId,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
// Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once.
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
@@ -14,6 +14,13 @@ export const UrlPreview = style([
},
]);
// Wider, responsive card for interactive embeds (video players, tweets, posts)
// so the player chrome / tweet content isn't cramped or clipped. Defined after
// UrlPreview so its width wins the cascade.
export const UrlPreviewWide = style({
width: 'min(38rem, 94vw)',
});
export const UrlPreviewImg = style([
DefaultReset,
{
@@ -209,6 +216,9 @@ export const EmbedMediaLandscape = style([
aspectRatio: '16 / 9',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
},
},
]);
@@ -216,12 +226,15 @@ export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
width: toRem(220),
width: toRem(300),
maxWidth: '100%',
aspectRatio: '9 / 16',
margin: '0 auto',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto', margin: 0 },
},
},
]);
@@ -242,6 +255,12 @@ export const EmbedFacade = style([
':hover': {
filter: 'brightness(0.85)',
},
selectors: {
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: '-2px',
},
},
},
]);
@@ -257,6 +276,19 @@ export const EmbedIframe = style([
},
]);
// 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([
DefaultReset,
{
width: '100%',
maxWidth: toRem(550),
margin: '0 auto',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
@@ -271,6 +303,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)
// ---------------------------------------------------------------------------
@@ -389,6 +432,51 @@ export const BadgeTikTok = style({
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',
});
export const BadgeInstagram = style({
backgroundColor: '#e1306c',
color: '#ffffff',
});
export const BadgeBluesky = style({
backgroundColor: '#0085ff',
color: '#ffffff',
});
export const BadgeLoom = style({
backgroundColor: '#625df5',
color: '#ffffff',
});
export const BadgeKick = style({
backgroundColor: '#53fc18',
color: '#000000',
});
export const BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
+504 -147
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -20,12 +20,15 @@ import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
buildVideoEmbedUrl,
getVimeoVideoId,
getYouTubeVideoId,
getYoutubeShortsId,
isYouTubeShorts,
VideoEmbedProvider,
extractEmbedHeight,
getTikTokVideoId,
getTweetId,
isTikTokLink,
MediaEmbed,
parseMediaEmbed,
tiktokIdFromOembed,
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main };
@@ -35,10 +38,7 @@ const linkStyles = { color: color.Success.Main };
// ---------------------------------------------------------------------------
type CardVariant =
| 'youtube-shorts'
| 'youtube'
| 'tiktok'
| 'vimeo'
| 'github'
| 'twitter'
| 'reddit'
@@ -283,11 +283,10 @@ function isTenor(url: string): boolean {
}
function getCardVariant(url: string): CardVariant {
// Shorts must be detected before generic youtube
if (isYouTubeShorts(url)) return 'youtube-shorts';
if (getYouTubeVideoId(url) !== null) return 'youtube';
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
// non-embed fallbacks (incl. TikTok short links with no resolvable id).
if (isTikTok(url)) return 'tiktok';
if (getVimeoVideoId(url) !== null) return 'vimeo';
if (isGitHubRepo(url)) return 'github';
if (isTwitter(url)) return 'twitter';
if (getRedditSubreddit(url) !== null) return 'reddit';
@@ -435,25 +434,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
}
// ---------------------------------------------------------------------------
// Card 1: YouTube Shorts
// ---------------------------------------------------------------------------
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
// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------
function TikTokCard({
@@ -573,6 +554,76 @@ function TikTokCard({
// Card 3: Twitter / X
// ---------------------------------------------------------------------------
// Origins allowed to drive a self-resizing embed (stable refs → effect deps).
const TWITTER_ORIGINS = ['https://platform.twitter.com', 'https://platform.x.com'];
const INSTAGRAM_ORIGINS = ['https://www.instagram.com'];
const REDDIT_ORIGINS = ['https://embed.reddit.com'];
const BLUESKY_ORIGINS = ['https://embed.bsky.app'];
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';
// 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 }) {
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
return (
<iframe
ref={ref}
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={EMBED_SANDBOX}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
);
}
function TwitterCard({
url,
prev,
@@ -584,12 +635,16 @@ function TwitterCard({
mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean;
}) {
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [expanded, setExpanded] = useState(false);
const rawTitle = (prev['og:title'] as string | undefined) ?? '';
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const imageWidth = prev['og:image:width'] as number | undefined;
const isTweet = isTwitterTweet(url);
const tweetId = getTweetId(url);
const { name, handle, text } = parseTweetTitle(rawTitle);
const tweetText = text ?? (isTweet ? rawDescription : undefined);
@@ -600,22 +655,59 @@ function TwitterCard({
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
: null;
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
const header = (
<div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div>
);
// Expanded: the interactive, self-sizing post embed (playable video, gallery…).
if (canEmbed && expanded && tweetId) {
return (
<>
<div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
</UrlPreviewContent>
</>
);
}
return (
<>
{/* Header row */}
<div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<SiteBadge label="𝕏" colorClass={previewCss.BadgeTwitter} />
</div>
{header}
<UrlPreviewContent>
{isTweet && tweetText ? (
@@ -638,17 +730,29 @@ function TwitterCard({
/>
)}
<Text
size="T200"
priority="300"
style={{ opacity: 0.5 }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
{getDomain(url)}
</Text>
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
size="T200"
priority="300"
style={{ opacity: 0.5 }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
{getDomain(url)}
</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>
</>
);
@@ -922,30 +1026,73 @@ function RedditCard({
// in the (cookie-less) YouTube / Vimeo iframe, so nothing loads from the third
// 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.
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 },
tidal: { label: 'TIDAL', class: previewCss.BadgeTidal },
instagram: { label: 'Instagram', class: previewCss.BadgeInstagram },
reddit: { label: 'Reddit', class: previewCss.BadgeReddit },
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
loom: { label: 'Loom', class: previewCss.BadgeLoom },
kick: { label: 'Kick', class: previewCss.BadgeKick },
};
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
// "please wait for verification" page; don't surface that as the caption.
const looksLikeBotWall = (s: string): boolean =>
/please wait|are you a (human|robot)|verifying|verification|just a moment|attention required/i.test(
s,
);
// 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,
prev,
provider,
videoId,
portrait,
siteBadgeLabel,
siteBadgeClass,
embed,
}: {
url: string;
prev: IPreviewUrlResponse;
provider: VideoEmbedProvider;
videoId: string;
portrait?: boolean;
siteBadgeLabel: string;
siteBadgeClass: string;
embed: MediaEmbed;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
const portrait = embed.kind === 'portrait';
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
const rich = 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
: embed.provider === 'bluesky'
? BLUESKY_ORIGINS
: REDDIT_ORIGINS;
// Only subscribe to resize messages while the iframe is actually mounted.
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
playing ? resizeOrigins : NO_RESIZE_ORIGINS,
embed.height ?? 480,
);
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
const description = looksLikeBotWall(rawDescription) ? '' : rawDescription;
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(
@@ -959,10 +1106,22 @@ function VideoEmbedCard({
)
: 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 = fixedHeight
? previewCss.EmbedMediaAudio
: portrait
? previewCss.EmbedMediaPortrait
: previewCss.EmbedMediaLandscape;
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
const thumb = thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
// alt="" — the parent button/link already carries the accessible name.
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} />
@@ -976,61 +1135,108 @@ function VideoEmbedCard({
</span>
);
const enterFullscreen = () => {
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
};
return (
<Box direction="Column">
<div className={mediaClass}>
{playing ? (
<iframe
className={previewCss.EmbedIframe}
src={buildVideoEmbedUrl(provider, videoId)}
title={title || siteBadgeLabel}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allowFullScreen
loading="lazy"
/>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)}
aria-label={`Play: ${title || siteBadgeLabel}`}
>
{thumb}
{playOverlay}
</button>
) : (
<a
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
ref={resizeRef}
className={previewCss.EmbedIframeStatic}
src={embed.embedUrl}
title={title || badge.label}
style={{ height: `${resizeHeight}px` }}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
scrolling="no"
/>
) : (
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
<iframe
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
/>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={() => setPlaying(true)}
aria-label={`Play: ${title || badge.label}`}
>
{thumb}
{playOverlay}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
>
{thumb}
{playOverlay}
</a>
)}
</div>
)}
<UrlPreviewContent>
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
size="T200"
priority="300"
>
{thumb}
{playOverlay}
</a>
)}
</div>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
</Text>
{title && (
<SiteBadge label={badge.label} colorClass={badge.class} />
</Text>
{playing && (
<Box alignItems="Center" gap="100" shrink="No">
{video && (
<Chip
variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{/* While a video plays, drop the title/description so the player isn't
squeezed by text below it. */}
{!(playing && video) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
{!(playing && video) && description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
@@ -1040,29 +1246,160 @@ function VideoEmbedCard({
);
}
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
return (
<VideoEmbedCard
url={url}
prev={prev}
provider="youtube"
videoId={getYouTubeVideoId(url) ?? ''}
siteBadgeLabel="YouTube"
siteBadgeClass=""
/>
);
}
// TikTok needs its own card: short "copy-link" URLs (vm.tiktok.com, tiktok.com/t/…)
// don't carry the video id, and the homeserver's link preview is bot-walled. So we
// resolve the id client-side via TikTok's CORS-enabled oEmbed API (on click, to
// keep the facade privacy model), then play the player/v1 embed.
function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [videoId, setVideoId] = useState<string | null>(() => getTikTokVideoId(url));
const [playing, setPlaying] = useState(false);
const [resolving, setResolving] = useState(false);
const [failed, setFailed] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
useEffect(() => () => abortRef.current?.abort(), []);
const mxcImage = prev['og:image'] as string | undefined;
const rawTitle = prev['og:title'] ?? '';
const title = looksLikeBotWall(rawTitle) ? '' : rawTitle;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 320, 568, 'scale', false)
: undefined;
const start = useCallback(async () => {
if (videoId) {
setPlaying(true);
return;
}
setResolving(true);
setFailed(false);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
const id = tiktokIdFromOembed(await res.json());
if (controller.signal.aborted) return;
if (id) {
setVideoId(id);
setPlaying(true);
} else {
setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
} finally {
if (!controller.signal.aborted) setResolving(false);
}
}, [url, videoId]);
const enterFullscreen = () => mediaRef.current?.requestFullscreen?.().catch(() => undefined);
const facadeInner = (
<>
{thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : (
<span className={previewCss.EmbedPlaceholder} />
)}
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
{resolving ? <Spinner size="100" /> : <Icon size="400" src={Icons.Play} />}
</span>
</span>
</>
);
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
return (
<VideoEmbedCard
url={url}
prev={prev}
provider="vimeo"
videoId={getVimeoVideoId(url) ?? ''}
siteBadgeLabel="Vimeo"
siteBadgeClass={previewCss.BadgeVimeo}
/>
<Box direction="Column">
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe
className={previewCss.EmbedIframe}
src={tiktokPlayerEmbedUrl(videoId)}
title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
/>
) : inlineMediaEmbeds ? (
<button
type="button"
className={previewCss.EmbedFacade}
onClick={start}
disabled={resolving}
aria-busy={resolving}
aria-label={
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
>
{facadeInner}
</button>
) : (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Open on TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</a>
)}
</div>
<UrlPreviewContent>
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
</Text>
{playing && videoId && (
<Box alignItems="Center" gap="100" shrink="No">
<Chip
variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)}
</Box>
{failed && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Couldnt load this TikTok open it on TikTok.
</Text>
)}
{!(playing && videoId) && title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
@@ -1671,18 +2008,38 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
// Interactive embeds (players, tweets) render in a wider, responsive card so
// player chrome / tweet content isn't cramped or clipped.
const embed = parseMediaEmbed(url, window.location.hostname);
const wide = !!embed || isTwitterTweet(url);
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
// redirects) don't carry the id, so fall back to the canonical og:url that
// the homeserver already resolved when fetching the preview.
const ogUrl = prev['og:url'];
const resolvedEmbed =
embed ??
(typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null);
if (resolvedEmbed) {
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
}
// TikTok video links (incl. short share links) get their own oEmbed-resolving card.
if (isTikTokLink(url)) {
return <TikTokEmbedCard url={url} prev={prev} />;
}
const variant = getCardVariant(url);
switch (variant) {
case 'youtube-shorts':
return <YouTubeShortsCard url={url} prev={prev} />;
case 'youtube':
return <YouTubeCard url={url} prev={prev} />;
case 'tiktok':
return <TikTokCard url={url} prev={prev} mx={mx} useAuthentication={useAuthentication} />;
case 'vimeo':
return <VimeoCard url={url} prev={prev} />;
case 'github':
return <GitHubCard url={url} prev={prev} />;
case 'twitter':
@@ -1764,14 +2121,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const content = renderContent(previewStatus.data);
if (content === null) return null;
return (
<UrlPreview {...props} ref={ref}>
<UrlPreview {...props} ref={ref} className={cardClass}>
{content}
</UrlPreview>
);
}
return (
<UrlPreview {...props} ref={ref}>
<UrlPreview {...props} ref={ref} className={cardClass}>
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
@@ -2348,7 +2348,7 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
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={
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
}
+233 -35
View File
@@ -5,65 +5,263 @@ import {
getYoutubeShortsId,
isYouTubeShorts,
getVimeoVideoId,
parseVideoEmbed,
getVimeoParts,
extractEmbedHeight,
getTikTokVideoId,
isTikTokLink,
tiktokIdFromOembed,
tiktokPlayerEmbedUrl,
getDailymotionId,
getStreamableId,
getTwitchTarget,
getSpotifyEmbedTarget,
isSoundCloudTrack,
getAppleMusicEmbed,
getTweetId,
getTidalEmbed,
getInstagramEmbed,
getRedditPostEmbed,
getBlueskyEmbed,
getLoomId,
getKickChannel,
buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
} 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://youtu.be/dQw4w9WgXcQ'), '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://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
});
test('getYouTubeVideoId: non-YouTube / malformed → null', () => {
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('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/watch?v=abc123'), false);
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
});
test('getVimeoVideoId', () => {
test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
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://youtube.com/watch?v=x'), 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'));
// channel / group / album share a trailing numeric video id
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
});
test('parseVideoEmbed: routes provider + portrait for shorts', () => {
assert.deepEqual(parseVideoEmbed('https://youtube.com/shorts/abc'), {
provider: 'youtube',
id: 'abc',
portrait: true,
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
assert.equal(extractEmbedHeight({ type: 'MEASURE', details: { height: 640 } }), 640);
assert.equal(extractEmbedHeight({ type: 'resize.embed', data: 812 }), 812); // Reddit
assert.equal(
extractEmbedHeight({ 'twttr.embed': [{ method: 'twttr.private.resize', params: [{ height: 500 }] }] }),
500,
);
assert.equal(extractEmbedHeight({ height: 300 }), 300); // generic fallback
assert.equal(extractEmbedHeight({ type: 'other' }), undefined);
assert.equal(extractEmbedHeight('not-an-object'), undefined);
});
test('mobile Shorts (m.youtube.com) → portrait', () => {
assert.equal(parseMediaEmbed('https://m.youtube.com/shorts/abc123', 'h')?.kind, 'portrait');
});
test('TikTok: canonical /video/<id> only', () => {
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user/video/7234567890123456789'), '7234567890123456789');
assert.equal(getTikTokVideoId('https://vm.tiktok.com/ZMabc/'), null); // short link → oEmbed
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
});
test('isTikTokLink: canonical + short + vm/vt', () => {
assert.equal(isTikTokLink('https://www.tiktok.com/@user/video/123'), true);
assert.equal(isTikTokLink('https://www.tiktok.com/t/ZTSHuuXWq/'), true);
assert.equal(isTikTokLink('https://vm.tiktok.com/ZMabc/'), true);
assert.equal(isTikTokLink('https://www.tiktok.com/@user'), false); // profile, not a video
assert.equal(isTikTokLink('https://youtube.com/watch?v=x'), false);
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', 'h'), null); // handled by its own card
});
test('tiktokIdFromOembed + player url', () => {
assert.equal(tiktokIdFromOembed({ embed_product_id: '7659555276823006478' }), '7659555276823006478');
assert.equal(tiktokIdFromOembed({ html: '<blockquote data-video-id="123456">' }), '123456');
assert.equal(tiktokIdFromOembed({}), null);
assert.equal(tiktokPlayerEmbedUrl('999'), 'https://www.tiktok.com/player/v1/999?autoplay=1&rel=0');
});
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'), {
provider: 'youtube',
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/videos/123456789'), {
type: 'video',
value: '123456789',
});
assert.deepEqual(getTwitchTarget('https://clips.twitch.tv/FunnyClipSlug'), {
type: 'clip',
value: 'FunnyClipSlug',
});
assert.deepEqual(getTwitchTarget('https://www.twitch.tv/streamer/clip/CoolSlug'), {
type: 'clip',
value: 'CoolSlug',
});
});
test('Spotify target + height', () => {
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT'), {
type: 'track',
id: '4cOdK2wGLETKBW3PvgPWqT',
});
assert.deepEqual(getSpotifyEmbedTarget('https://open.spotify.com/intl-de/album/xyz'), {
type: 'album',
id: 'xyz',
portrait: false,
});
assert.deepEqual(parseVideoEmbed('https://vimeo.com/42'), {
provider: 'vimeo',
id: '42',
portrait: false,
});
assert.equal(parseVideoEmbed('https://example.com/video'), null);
assert.equal(getSpotifyEmbedTarget('https://open.spotify.com/'), null);
assert.equal(spotifyEmbedHeight('track'), 152);
assert.equal(spotifyEmbedHeight('album'), 352);
});
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
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
});
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', '123456789'),
'https://player.vimeo.com/video/123456789?autoplay=1',
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',
);
// id is URL-encoded (defense-in-depth; ids are normally already safe)
assert.ok(buildVideoEmbedUrl('youtube', 'a/b').includes('a%2Fb'));
});
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&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');
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('Tidal: track (audio) vs video (landscape)', () => {
assert.deepEqual(getTidalEmbed('https://tidal.com/browse/track/12345'), {
kind: 'audio',
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?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);
});
test('Instagram: p / reel / tv → embed path', () => {
assert.equal(getInstagramEmbed('https://www.instagram.com/p/AbC123_-/'), 'https://www.instagram.com/p/AbC123_-/embed/');
assert.equal(getInstagramEmbed('https://instagram.com/reel/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(getInstagramEmbed('https://www.instagram.com/reels/XyZ/'), 'https://www.instagram.com/reel/XyZ/embed/');
assert.equal(getInstagramEmbed('https://www.instagram.com/someuser/'), null);
});
test('Reddit post embed → redditmedia', () => {
assert.equal(
getRedditPostEmbed('https://www.reddit.com/r/aww/comments/abc123/cute_cat/'),
'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://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
// redd.it short link → comments-only embed (no subreddit in the redirect)
assert.equal(getRedditPostEmbed('https://redd.it/1abc23'), 'https://embed.reddit.com/comments/1abc23/?ref_source=embed&ref=share&embed=true&theme=dark');
});
test('parseMediaEmbed: Instagram/Reddit → rich, Tidal → audio', () => {
assert.equal(parseMediaEmbed('https://www.instagram.com/p/abc/', 'h')?.kind, 'rich');
assert.equal(parseMediaEmbed('https://www.reddit.com/r/x/comments/y/z/', 'h')?.provider, 'reddit');
assert.equal(parseMediaEmbed('https://tidal.com/browse/track/1', 'h')?.provider, 'tidal');
});
test('Bluesky / Loom / Kick', () => {
assert.equal(
getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social/post/3kabc'),
'https://embed.bsky.app/embed/alice.bsky.social/app.bsky.feed.post/3kabc',
);
assert.equal(getBlueskyEmbed('https://bsky.app/profile/alice.bsky.social'), null);
assert.equal(parseMediaEmbed('https://bsky.app/profile/a.bsky.social/post/3k', 'h')?.kind, 'rich');
assert.equal(getLoomId('https://www.loom.com/share/abc123DEF'), 'abc123DEF');
assert.equal(getLoomId('https://www.loom.com/embed/abc123DEF'), 'abc123DEF');
assert.equal(
parseMediaEmbed('https://www.loom.com/share/xyz', 'h')?.embedUrl,
'https://www.loom.com/embed/xyz',
);
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
assert.ok(
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
'player.kick.com/streamer?autoplay=true',
),
);
});
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'));
});
+493 -41
View File
@@ -1,35 +1,39 @@
// Pure helpers for detecting embeddable video URLs (YouTube, Shorts, Vimeo) and
// building their privacy-friendly embed URLs. No React/DOM/CSS imports so this
// stays unit-testable in isolation.
// 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
// audio → short fixed-height player (Spotify/SoundCloud/Tidal/Apple Music)
// rich → tall fixed-height post embed (Instagram / Reddit)
export type VideoEmbedProvider = 'youtube' | 'vimeo';
export type EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
export type VideoEmbed = {
provider: VideoEmbedProvider;
id: string;
portrait: boolean; // YouTube Shorts render 9:16
export type MediaEmbed = {
provider: string;
kind: EmbedKind;
embedUrl: string;
/** Fixed pixel height for audio/rich embeds (aspect-ratio is used for video). */
height?: number;
};
// --- 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);
// youtu.be/<id>
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
// youtube.com/watch?v=<id>
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
if (YOUTUBE_HOSTS.includes(hostname)) {
if (pathname === '/watch') return searchParams.get('v');
// youtube.com/embed/<id>
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
if (embedMatch) return embedMatch[1];
// youtube.com/shorts/<id> (also matched by getYoutubeShortsId)
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 malformed URLs
/* ignore */
}
return null;
}
@@ -37,7 +41,7 @@ export function getYouTubeVideoId(url: string): string | null {
export function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
if (!YOUTUBE_HOSTS.includes(hostname)) return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch {
return false;
@@ -47,7 +51,7 @@ export function isYouTubeShorts(url: string): boolean {
export function getYoutubeShortsId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
if (!YOUTUBE_HOSTS.includes(hostname)) return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
@@ -55,36 +59,484 @@ export function getYoutubeShortsId(url: string): string | null {
}
}
export function getVimeoVideoId(url: string): string | null {
// --- Vimeo ----------------------------------------------------------------
/** 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+)/);
// 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;
} 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). */
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;
}
}
/** Detect an embeddable video from any URL, or null if it isn't one. */
export function parseVideoEmbed(url: string): VideoEmbed | null {
const shortsId = getYoutubeShortsId(url);
if (shortsId) return { provider: 'youtube', id: shortsId, portrait: true };
const ytId = getYouTubeVideoId(url);
if (ytId) return { provider: 'youtube', id: ytId, portrait: false };
const vimeoId = getVimeoVideoId(url);
if (vimeoId) return { provider: 'vimeo', id: vimeoId, portrait: false };
/** 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;
}
/**
* Build the click-to-play embed URL. YouTube uses the cookie-less
* youtube-nocookie host so nothing is set until the user presses play.
* 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 buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string {
const safeId = encodeURIComponent(id);
if (provider === 'vimeo') {
return `https://player.vimeo.com/video/${safeId}?autoplay=1`;
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;
}
}
}
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`;
if (typeof d.height === 'number') return d.height;
return undefined;
}
export function tiktokPlayerEmbedUrl(id: string): string {
// 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`;
}
// --- 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;
}
// --- 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);
// 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;
// /<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; 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, video };
} 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;
}
}
// --- 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+)/);
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]}?layout=gridify`,
height: 275,
};
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) ---
const REDDIT_EMBED_QS = '?ref_source=embed&ref=share&embed=true&theme=dark';
export function getRedditPostEmbed(url: string): string | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
// 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;
}
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;
// embed.reddit.com is the current host (www.redditmedia.com now 301s here).
return `https://embed.reddit.com/r/${m[1]}/comments/${m[2]}/${REDDIT_EMBED_QS}`;
} catch {
return null;
}
}
// --- Loom -----------------------------------------------------------------
export function getLoomId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'loom.com') return null;
const m = pathname.match(/^\/(?:share|embed)\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'kick.com') return null;
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
} catch {
return null;
}
}
// --- Bluesky --------------------------------------------------------------
export function getBlueskyEmbed(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'bsky.app' && hostname !== 'www.bsky.app') return null;
const m = pathname.match(/^\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)/);
// authority is a handle or did; embed.bsky.app resolves either.
return m ? `https://embed.bsky.app/embed/${m[1]}/app.bsky.feed.post/${m[2]}` : null;
} catch {
return null;
}
}
// --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent;
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. */
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 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
// resolve the video id, which a sync parser can't do. See isTikTokLink below.
const dmId = getDailymotionId(url);
if (dmId)
return {
provider: 'dailymotion',
kind: 'landscape',
// 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);
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: apple.video ? 'landscape' : 'audio',
embedUrl: apple.embedUrl,
height: apple.video ? undefined : apple.height,
};
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 };
const bsky = getBlueskyEmbed(url);
if (bsky) return { provider: 'bluesky', kind: 'rich', embedUrl: bsky, height: 600 };
const loomId = getLoomId(url);
if (loomId)
return {
provider: 'loom',
kind: 'landscape',
embedUrl: `https://www.loom.com/embed/${enc(loomId)}`,
};
const kick = getKickChannel(url);
if (kick)
return {
provider: 'kick',
kind: 'landscape',
embedUrl: `https://player.kick.com/${enc(kick)}?autoplay=true`,
};
return null;
}