Compare commits

..

4 Commits

Author SHA1 Message Date
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
jared 93f307cd63 feat(embeds): inline YouTube/Vimeo players + media-forward video tiles
CI / Build & Quality Checks (push) Successful in 11m17s
CI / Trigger Desktop Build (push) Successful in 10s
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.

Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.

Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).

Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:48:12 -04:00
6 changed files with 996 additions and 273 deletions
@@ -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(34rem, 92vw)',
});
export const UrlPreviewImg = style([
DefaultReset,
{
@@ -197,6 +204,107 @@ export const MediaPlayButton = style([
},
]);
// ---------------------------------------------------------------------------
// Inline video embed (YouTube / Vimeo) — media-forward click-to-play facade
// ---------------------------------------------------------------------------
export const EmbedMediaLandscape = style([
DefaultReset,
{
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
selectors: {
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
},
},
]);
export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
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 },
},
},
]);
// Fills the aspect box; used for both the <button> facade and the <a> fallback.
export const EmbedFacade = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.85)',
},
},
]);
export const EmbedIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
display: 'block',
},
]);
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
export const EmbedIframeStatic = style([
DefaultReset,
{
width: '100%',
border: 0,
display: 'block',
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#111111',
color: '#ffffff',
fontSize: toRem(28),
},
]);
// 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)
// ---------------------------------------------------------------------------
@@ -315,6 +423,36 @@ 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 BadgeTidal = style({
backgroundColor: '#000000',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
+299 -273
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';
@@ -17,6 +17,9 @@ import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { getTweetId, MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main };
@@ -25,10 +28,7 @@ const linkStyles = { color: color.Success.Main };
// ---------------------------------------------------------------------------
type CardVariant =
| 'youtube-shorts'
| 'youtube'
| 'tiktok'
| 'vimeo'
| 'github'
| 'twitter'
| 'reddit'
@@ -44,53 +44,6 @@ type CardVariant =
| 'tenor'
| 'generic';
function getYouTubeVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname, searchParams } = parsed;
// youtu.be/<id>
if (hostname === 'youtu.be') {
const id = pathname.slice(1).split('/')[0];
return id || null;
}
// youtube.com/watch?v=<id>
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') {
return searchParams.get('v');
}
// youtube.com/shorts/<id> — handled by isYouTubeShorts
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
// ignore malformed URLs
}
return null;
}
function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
return /^\/shorts\/[A-Za-z0-9_-]+/.test(pathname);
} catch {
return false;
}
}
function getYoutubeShortsId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isTikTok(url: string): boolean {
try {
const { hostname } = new URL(url);
@@ -111,18 +64,6 @@ function getTikTokUsername(url: string): string | null {
}
}
function getVimeoVideoId(url: string): string | null {
try {
const parsed = new URL(url);
const { hostname, pathname } = parsed;
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
function isGitHubRepo(url: string): boolean {
try {
const parsed = new URL(url);
@@ -332,11 +273,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';
@@ -484,157 +424,7 @@ function SiteBadge({ label, colorClass }: { label: string; colorClass: string })
}
// ---------------------------------------------------------------------------
// Shared VideoCard — used by YouTube (non-Shorts) and Vimeo
// ---------------------------------------------------------------------------
function VideoCard({
url,
prev,
thumbnailUrl,
siteBadgeLabel,
siteBadgeClass,
showPlayButton,
}: {
url: string;
prev: IPreviewUrlResponse;
thumbnailUrl: string;
siteBadgeLabel: string;
siteBadgeClass: string;
showPlayButton?: boolean;
}) {
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
return (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.MediaThumbnailWrapper}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
>
<img
className={previewCss.MediaThumbnailImg}
src={thumbnailUrl}
alt={title}
loading="lazy"
/>
{showPlayButton !== false && (
<div className={previewCss.MediaPlayOverlay}>
<div className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</div>
</div>
)}
</a>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</>
);
}
// ---------------------------------------------------------------------------
// Card 1: YouTube Shorts
// ---------------------------------------------------------------------------
function YouTubeShortsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYoutubeShortsId(url) ?? '';
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const title = prev['og:title'] ?? '';
// YouTube Shorts og:description is often the channel name
const rawDescription = (prev['og:description'] as string | undefined) ?? '';
const channelName = rawDescription.length < 80 ? rawDescription : null;
return (
<>
{/* Header bar with Shorts badge */}
<div className={previewCss.ShortsHeader}>
<SiteBadge label="Shorts" colorClass={previewCss.BadgeYouTubeShorts} />
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
YouTube
</Text>
</div>
{/* Side-by-side layout */}
<div className={previewCss.PortraitSideLayout}>
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.PortraitThumbnail}
aria-label={`Watch Short: ${title}`}
>
<img
className={previewCss.PortraitThumbnailImg}
src={thumbnailSrc}
alt={title}
loading="lazy"
/>
<div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}></div>
</div>
</a>
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{channelName && (
<Text size="T200" priority="300" style={{ opacity: 0.65 }}>
{channelName}
</Text>
)}
<Text
size="T200"
priority="300"
style={{ opacity: 0.5, marginTop: 'auto' }}
as="a"
href={url}
target="_blank"
rel="noreferrer"
>
youtube.com
</Text>
</Box>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Card 2: TikTok
// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------
function TikTokCard({
@@ -754,6 +544,61 @@ function TikTokCard({
// Card 3: Twitter / X
// ---------------------------------------------------------------------------
// Interactive X/Twitter post embed. The official platform.twitter.com iframe
// renders the full post — playable video/GIF, image galleries, quote tweets —
// and reports its height back to us via postMessage, which we apply so the card
// grows to fit. Scoped to messages from OUR iframe + the platform.twitter.com
// origin. Only mounted after the user clicks "View post" (privacy facade).
function TweetEmbed({ id }: { id: string }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [height, setHeight] = useState(320);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.origin !== 'https://platform.twitter.com') return;
if (iframeRef.current && e.source !== iframeRef.current.contentWindow) return;
let payload: unknown = e.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
return;
}
}
const embed = (payload as { 'twttr.embed'?: unknown } | null)?.['twttr.embed'];
const calls = Array.isArray(embed) ? embed : embed ? [embed] : [];
calls.forEach((c: { method?: string; params?: Array<{ height?: number }> }) => {
const h = c?.method === 'twttr.private.resize' ? c.params?.[0]?.height : undefined;
if (typeof h === 'number' && h > 0) setHeight(Math.min(Math.ceil(h), 1400));
});
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
return (
<iframe
ref={iframeRef}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
);
}
function TwitterCard({
url,
prev,
@@ -765,12 +610,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);
@@ -781,22 +630,39 @@ 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 (
<>
{header}
<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 ? (
@@ -819,17 +685,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>
</>
);
@@ -1098,33 +976,175 @@ function RedditCard({
// Remaining card variants (unchanged from original)
// ---------------------------------------------------------------------------
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const videoId = getYouTubeVideoId(url)!;
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
// Media-forward video tile with a privacy-friendly click-to-play facade: shows
// the homeserver's cached thumbnail + a play button; only on click does it swap
// 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.
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 },
};
return (
<VideoCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
siteBadgeLabel="YouTube"
siteBadgeClass=""
/>
// 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,
);
}
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
// Vimeo always includes og:image for video thumbnails
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
// 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,
embed,
}: {
url: string;
prev: IPreviewUrlResponse;
embed: MediaEmbed;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
const portrait = embed.kind === 'portrait';
const video = embed.kind === 'landscape' || embed.kind === 'portrait';
const fixedHeight = embed.kind === 'audio' || embed.kind === 'rich';
// '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(
mx,
mxcImage,
useAuthentication,
portrait ? 320 : 480,
portrait ? 568 : 270,
'scale',
false,
)
: undefined;
// 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" />
) : (
<span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} />
</span>
);
const playOverlay = (
<span className={previewCss.MediaPlayOverlay}>
<span className={previewCss.MediaPlayButton}>
<Icon size="400" src={Icons.Play} />
</span>
</span>
);
const enterFullscreen = () => {
mediaRef.current?.requestFullscreen?.().catch(() => undefined);
};
return (
<VideoCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
siteBadgeLabel="Vimeo"
siteBadgeClass={previewCss.BadgeVimeo}
/>
<Box direction="Column">
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
// No sandbox: these are CSP-allowlisted trusted hosts, and sandboxing
// breaks player features (fullscreen, TikTok's flow, etc.).
<iframe
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
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={`Open on ${badge.label}: ${title}`}
>
{thumb}
{playOverlay}
</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={badge.label} colorClass={badge.class} />
</Text>
{playing && video && (
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen">
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
</Box>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
@@ -1733,18 +1753,24 @@ 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.
if (embed) {
return <MediaEmbedCard url={url} prev={prev} embed={embed} />;
}
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':
@@ -1826,14 +1852,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>
@@ -2250,6 +2250,7 @@ function Messages() {
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom,
@@ -2344,6 +2345,15 @@ function Messages() {
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Inline Media Players"
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} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"
+2
View File
@@ -182,6 +182,7 @@ export interface Settings {
mediaAutoLoad: boolean;
urlPreview: boolean;
encUrlPreview: boolean;
inlineMediaEmbeds: boolean;
showHiddenEvents: boolean;
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
// retention window passes (default off — nothing auto-deletes by surprise).
@@ -290,6 +291,7 @@ const defaultSettings: Settings = {
mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: true,
inlineMediaEmbeds: true,
showHiddenEvents: false,
enforceRetentionLocally: false,
legacyUsernameColor: false,
+182
View File
@@ -0,0 +1,182 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getYouTubeVideoId,
getYoutubeShortsId,
isYouTubeShorts,
getVimeoVideoId,
getTikTokVideoId,
getDailymotionId,
getStreamableId,
getTwitchTarget,
getSpotifyEmbedTarget,
isSoundCloudTrack,
getAppleMusicEmbed,
getTweetId,
getTidalEmbed,
getInstagramEmbed,
getRedditPostEmbed,
buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
} from './videoEmbed';
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?t=42'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://www.youtube.com/embed/dQw4w9WgXcQ'), 'dQw4w9WgXcQ');
assert.equal(getYouTubeVideoId('https://youtube.com/shorts/abc123DEF_-'), 'abc123DEF_-');
assert.equal(getYouTubeVideoId('https://vimeo.com/123'), null);
assert.equal(isYouTubeShorts('https://www.youtube.com/shorts/abc123'), true);
assert.equal(getYoutubeShortsId('https://youtube.com/shorts/abc123'), 'abc123');
});
test('Vimeo', () => {
assert.equal(getVimeoVideoId('https://vimeo.com/123456789'), '123456789');
assert.equal(getVimeoVideoId('https://vimeo.com/channels/staffpicks'), null);
});
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 redirects
assert.equal(getTikTokVideoId('https://www.tiktok.com/@user'), null);
});
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(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',
});
assert.equal(getSpotifyEmbedTarget('https://open.spotify.com/'), null);
assert.equal(spotifyEmbedHeight('track'), 152);
assert.equal(spotifyEmbedHeight('album'), 352);
});
test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
assert.equal(
buildVideoEmbedUrl('youtube', 'dQw4w9WgXcQ'),
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
);
assert.equal(buildVideoEmbedUrl('vimeo', '42'), 'https://player.vimeo.com/video/42?autoplay=1');
});
test('parseMediaEmbed: routes provider + kind, builds embed URLs', () => {
assert.deepEqual(parseMediaEmbed('https://youtube.com/shorts/abc', HOST), {
provider: 'youtube',
kind: 'portrait',
embedUrl: 'https://www.youtube-nocookie.com/embed/abc?autoplay=1&rel=0',
});
assert.equal(parseMediaEmbed('https://www.youtube.com/watch?v=xyz', HOST)?.kind, 'landscape');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.provider, 'tiktok');
assert.equal(parseMediaEmbed('https://www.tiktok.com/@u/video/123', HOST)?.kind, 'portrait');
assert.equal(parseMediaEmbed('https://streamable.com/abc', HOST)?.provider, 'streamable');
const spotify = parseMediaEmbed('https://open.spotify.com/track/abc', HOST);
assert.equal(spotify?.kind, 'audio');
assert.equal(spotify?.height, 152);
assert.equal(parseMediaEmbed('https://soundcloud.com/a/b', HOST)?.provider, 'soundcloud');
assert.equal(parseMediaEmbed('https://example.com/x', HOST), null);
});
test('Apple Music: album vs single song height, embed host swap', () => {
const album = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456');
assert.equal(album?.embedUrl, 'https://embed.music.apple.com/us/album/some-album/123456');
assert.equal(album?.height, 450);
const song = getAppleMusicEmbed('https://music.apple.com/us/album/some-album/123456?i=789');
assert.equal(song?.height, 175);
assert.ok(song?.embedUrl.includes('?i=789'));
assert.equal(getAppleMusicEmbed('https://music.apple.com/us/browse'), null);
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
assert.equal(getTweetId('https://x.com/user'), null);
assert.equal(getTweetId('https://youtube.com/watch?v=x'), null);
});
test('parseMediaEmbed: Apple Music → audio', () => {
const m = parseMediaEmbed('https://music.apple.com/us/album/x/1?i=2', 'chat.lotusguild.org');
assert.equal(m?.provider, 'applemusic');
assert.equal(m?.kind, 'audio');
assert.equal(m?.height, 175);
});
test('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');
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://www.redditmedia.com/r/aww/comments/abc123/?ref_source=embed&ref=share&embed=true&theme=dark',
);
assert.equal(getRedditPostEmbed('https://old.reddit.com/r/aww/comments/xyz/'), 'https://www.redditmedia.com/r/aww/comments/xyz/?ref_source=embed&ref=share&embed=true&theme=dark');
assert.equal(getRedditPostEmbed('https://www.reddit.com/r/aww/'), null); // subreddit, not a post
});
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('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'));
});
+365
View File
@@ -0,0 +1,365 @@
// 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 EmbedKind = 'landscape' | 'portrait' | 'audio' | 'rich';
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 --------------------------------------------------------------
export function getYouTubeVideoId(url: string): string | null {
try {
const { hostname, pathname, searchParams } = new URL(url);
if (hostname === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
if (pathname === '/watch') return searchParams.get('v');
const embedMatch = pathname.match(/^\/embed\/([A-Za-z0-9_-]+)/);
if (embedMatch) return embedMatch[1];
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
if (shortsMatch) return shortsMatch[1];
}
} catch {
/* ignore */
}
return null;
}
export function isYouTubeShorts(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return false;
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);
if (hostname !== 'www.youtube.com' && hostname !== 'youtube.com') return null;
const m = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- Vimeo ----------------------------------------------------------------
export function getVimeoVideoId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- TikTok ---------------------------------------------------------------
/** Only resolvable for canonical /video/<id> links (short vm.tiktok.com links redirect). */
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;
}
}
// --- 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);
if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length >= 2;
} catch {
return false;
}
}
// --- Apple Music ----------------------------------------------------------
/** music.apple.com/<cc>/album|playlist|song/<slug>/<id>[?i=<songId>] → embed player. */
export function getAppleMusicEmbed(url: string): { embedUrl: string; height: number } | null {
try {
const u = new URL(url);
if (u.hostname !== 'music.apple.com' && u.hostname !== 'embed.music.apple.com') return null;
if (!/\/(album|playlist|song|music-video)\//.test(u.pathname)) return null;
const embedUrl = `https://embed.music.apple.com${u.pathname}${u.search}`;
// A single song (?i=… on an album, or a /song/ link) is compact; collections are tall.
const isSong = u.searchParams.has('i') || /\/song\//.test(u.pathname);
return { embedUrl, height: isSong ? 175 : 450 };
} catch {
return null;
}
}
// --- X / Twitter ----------------------------------------------------------
export function getTweetId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com' && h !== 'mobile.twitter.com') return null;
const m = pathname.match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
// --- 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) return { kind: 'audio', embedUrl: `https://embed.tidal.com/albums/${m[1]}`, height: 400 };
m = p.match(/^\/playlist\/([0-9a-fA-F-]+)/);
if (m)
return { kind: 'audio', embedUrl: `https://embed.tidal.com/playlists/${m[1]}`, height: 400 };
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) ---
export function getRedditPostEmbed(url: string): string | null {
try {
const u = new URL(url);
const h = u.hostname.replace(/^(www|old|new|np|i)\./, '');
if (h !== 'reddit.com') return null;
const m = u.pathname.match(/^\/r\/([A-Za-z0-9_]+)\/comments\/([A-Za-z0-9]+)/);
if (!m) return null;
return `https://www.redditmedia.com/r/${m[1]}/comments/${m[2]}/?ref_source=embed&ref=share&embed=true&theme=dark`;
} catch {
return null;
}
}
// --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent;
export function buildVideoEmbedUrl(provider: 'youtube' | 'vimeo', id: string): string {
if (provider === 'vimeo') return `https://player.vimeo.com/video/${enc(id)}?autoplay=1`;
return `https://www.youtube-nocookie.com/embed/${enc(id)}?autoplay=1&rel=0`;
}
/** Spotify compact players (track/episode) are short; collections are taller. */
export function spotifyEmbedHeight(type: SpotifyType): number {
return type === 'track' || type === 'episode' ? 152 : 352;
}
/**
* Resolve any supported media URL to an embed spec, or null if none applies.
* `host` is the current page hostname — required by Twitch's `parent` param.
*/
export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
const shortsId = getYoutubeShortsId(url);
if (shortsId)
return { provider: 'youtube', kind: 'portrait', embedUrl: buildVideoEmbedUrl('youtube', shortsId) };
const ytId = getYouTubeVideoId(url);
if (ytId)
return { provider: 'youtube', kind: 'landscape', embedUrl: buildVideoEmbedUrl('youtube', ytId) };
const vimeoId = getVimeoVideoId(url);
if (vimeoId)
return { provider: 'vimeo', kind: 'landscape', embedUrl: buildVideoEmbedUrl('vimeo', vimeoId) };
const tiktokId = getTikTokVideoId(url);
if (tiktokId)
return {
provider: 'tiktok',
kind: 'portrait',
embedUrl: `https://www.tiktok.com/player/v1/${enc(tiktokId)}?music_info=1&description=1&rel=0`,
};
const dmId = getDailymotionId(url);
if (dmId)
return {
provider: 'dailymotion',
kind: 'landscape',
embedUrl: `https://www.dailymotion.com/embed/video/${enc(dmId)}?autoplay=1`,
};
const streamableId = getStreamableId(url);
if (streamableId)
return {
provider: 'streamable',
kind: 'landscape',
embedUrl: `https://streamable.com/e/${enc(streamableId)}?autoplay=1`,
};
const twitch = getTwitchTarget(url);
if (twitch) {
const parent = enc(host);
const embedUrl =
twitch.type === 'clip'
? `https://clips.twitch.tv/embed?clip=${enc(twitch.value)}&parent=${parent}&autoplay=true`
: `https://player.twitch.tv/?${twitch.type}=${enc(twitch.value)}&parent=${parent}&autoplay=true`;
return { provider: 'twitch', kind: 'landscape', embedUrl };
}
const spotify = getSpotifyEmbedTarget(url);
if (spotify)
return {
provider: 'spotify',
kind: 'audio',
embedUrl: `https://open.spotify.com/embed/${spotify.type}/${enc(spotify.id)}`,
height: spotifyEmbedHeight(spotify.type),
};
if (isSoundCloudTrack(url))
return {
provider: 'soundcloud',
kind: 'audio',
embedUrl: `https://w.soundcloud.com/player/?url=${enc(
url,
)}&color=%23ff5500&auto_play=true&show_comments=false&hide_related=true`,
height: 166,
};
const apple = getAppleMusicEmbed(url);
if (apple)
return {
provider: 'applemusic',
kind: 'audio',
embedUrl: apple.embedUrl,
height: apple.height,
};
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 };
return null;
}