Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 791b1cb5ea | |||
| 6c903fe3e5 |
@@ -257,6 +257,16 @@ export const EmbedIframe = style([
|
||||
},
|
||||
]);
|
||||
|
||||
// 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,
|
||||
{
|
||||
@@ -271,6 +281,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 +410,26 @@ 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',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch LIVE badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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';
|
||||
@@ -19,14 +19,7 @@ import { ImageViewer } from '../image-viewer';
|
||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
buildVideoEmbedUrl,
|
||||
getVimeoVideoId,
|
||||
getYouTubeVideoId,
|
||||
getYoutubeShortsId,
|
||||
isYouTubeShorts,
|
||||
VideoEmbedProvider,
|
||||
} from '../../utils/videoEmbed';
|
||||
import { getTweetId, MediaEmbed, parseMediaEmbed } from '../../utils/videoEmbed';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
@@ -35,10 +28,7 @@ const linkStyles = { color: color.Success.Main };
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardVariant =
|
||||
| 'youtube-shorts'
|
||||
| 'youtube'
|
||||
| 'tiktok'
|
||||
| 'vimeo'
|
||||
| 'github'
|
||||
| 'twitter'
|
||||
| 'reddit'
|
||||
@@ -283,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';
|
||||
@@ -435,25 +424,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 +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,
|
||||
@@ -584,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);
|
||||
@@ -600,9 +630,9 @@ function TwitterCard({
|
||||
? mxcUrlToHttp(mx, mxcImage!, useAuthentication, 400, 200, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header row */}
|
||||
const canEmbed = isTweet && inlineMediaEmbeds && !!tweetId;
|
||||
|
||||
const header = (
|
||||
<div className={previewCss.TwitterHeader}>
|
||||
<XLogoIcon size={18} />
|
||||
<Text priority="400" style={{ fontWeight: 600 }} truncate>
|
||||
@@ -616,6 +646,23 @@ function TwitterCard({
|
||||
<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}
|
||||
|
||||
<UrlPreviewContent>
|
||||
{isTweet && tweetText ? (
|
||||
@@ -638,6 +685,7 @@ function TwitterCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
@@ -649,6 +697,17 @@ function TwitterCard({
|
||||
>
|
||||
{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,22 +981,31 @@ 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 },
|
||||
};
|
||||
|
||||
// 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();
|
||||
@@ -947,6 +1015,8 @@ function VideoEmbedCard({
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const portrait = embed.kind === 'portrait';
|
||||
const audio = embed.kind === 'audio';
|
||||
const thumbnailUrl = mxcImage
|
||||
? mxcUrlToHttp(
|
||||
mx,
|
||||
@@ -959,7 +1029,18 @@ 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 = audio
|
||||
? previewCss.EmbedMediaAudio
|
||||
: portrait
|
||||
? previewCss.EmbedMediaPortrait
|
||||
: previewCss.EmbedMediaLandscape;
|
||||
const mediaStyle = audio ? { height: `${embed.height ?? 152}px` } : undefined;
|
||||
|
||||
const thumb = thumbnailUrl ? (
|
||||
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" />
|
||||
@@ -978,12 +1059,12 @@ function VideoEmbedCard({
|
||||
|
||||
return (
|
||||
<Box direction="Column">
|
||||
<div className={mediaClass}>
|
||||
<div className={mediaClass} style={mediaStyle}>
|
||||
{playing ? (
|
||||
<iframe
|
||||
className={previewCss.EmbedIframe}
|
||||
src={buildVideoEmbedUrl(provider, videoId)}
|
||||
title={title || siteBadgeLabel}
|
||||
src={embed.embedUrl}
|
||||
title={title || badge.label}
|
||||
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||
allowFullScreen
|
||||
@@ -994,7 +1075,7 @@ function VideoEmbedCard({
|
||||
type="button"
|
||||
className={previewCss.EmbedFacade}
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label={`Play: ${title || siteBadgeLabel}`}
|
||||
aria-label={`Play: ${title || badge.label}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
@@ -1005,7 +1086,7 @@ function VideoEmbedCard({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.EmbedFacade}
|
||||
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
|
||||
aria-label={`Open on ${badge.label}: ${title}`}
|
||||
>
|
||||
{thumb}
|
||||
{playOverlay}
|
||||
@@ -1023,7 +1104,7 @@ function VideoEmbedCard({
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
|
||||
<SiteBadge label={badge.label} colorClass={badge.class} />
|
||||
</Text>
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
@@ -1040,32 +1121,6 @@ function VideoEmbedCard({
|
||||
);
|
||||
}
|
||||
|
||||
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
return (
|
||||
<VideoEmbedCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
provider="youtube"
|
||||
videoId={getYouTubeVideoId(url) ?? ''}
|
||||
siteBadgeLabel="YouTube"
|
||||
siteBadgeClass=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
return (
|
||||
<VideoEmbedCard
|
||||
url={url}
|
||||
prev={prev}
|
||||
provider="vimeo"
|
||||
videoId={getVimeoVideoId(url) ?? ''}
|
||||
siteBadgeLabel="Vimeo"
|
||||
siteBadgeClass={previewCss.BadgeVimeo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
@@ -1672,17 +1727,18 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud) gets the media-forward click-to-play tile.
|
||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||
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':
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -5,65 +5,142 @@ import {
|
||||
getYoutubeShortsId,
|
||||
isYouTubeShorts,
|
||||
getVimeoVideoId,
|
||||
parseVideoEmbed,
|
||||
getTikTokVideoId,
|
||||
getDailymotionId,
|
||||
getStreamableId,
|
||||
getTwitchTarget,
|
||||
getSpotifyEmbedTarget,
|
||||
isSoundCloudTrack,
|
||||
getAppleMusicEmbed,
|
||||
getTweetId,
|
||||
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://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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('parseVideoEmbed: routes provider + portrait for shorts', () => {
|
||||
assert.deepEqual(parseVideoEmbed('https://youtube.com/shorts/abc'), {
|
||||
provider: 'youtube',
|
||||
id: 'abc',
|
||||
portrait: true,
|
||||
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);
|
||||
});
|
||||
assert.deepEqual(parseVideoEmbed('https://www.youtube.com/watch?v=xyz'), {
|
||||
provider: 'youtube',
|
||||
|
||||
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',
|
||||
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
|
||||
});
|
||||
|
||||
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', '123456789'),
|
||||
'https://player.vimeo.com/video/123456789?autoplay=1',
|
||||
);
|
||||
// id is URL-encoded (defense-in-depth; ids are normally already safe)
|
||||
assert.ok(buildVideoEmbedUrl('youtube', 'a/b').includes('a%2Fb'));
|
||||
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('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'));
|
||||
});
|
||||
|
||||
+240
-32
@@ -1,35 +1,35 @@
|
||||
// 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)
|
||||
|
||||
export type VideoEmbedProvider = 'youtube' | 'vimeo';
|
||||
export type EmbedKind = 'landscape' | 'portrait' | 'audio';
|
||||
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
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 === 'youtu.be') return pathname.slice(1).split('/')[0] || null;
|
||||
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
||||
// youtube.com/watch?v=<id>
|
||||
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];
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed URLs
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export function getYoutubeShortsId(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vimeo ----------------------------------------------------------------
|
||||
|
||||
export function getVimeoVideoId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
@@ -66,25 +68,231 @@ export function getVimeoVideoId(url: string): string | 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 };
|
||||
// --- 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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the click-to-play embed URL. YouTube uses the cookie-less
|
||||
* youtube-nocookie host so nothing is set until the user presses play.
|
||||
* 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 buildVideoEmbedUrl(provider: VideoEmbedProvider, id: string): string {
|
||||
const safeId = encodeURIComponent(id);
|
||||
if (provider === 'vimeo') {
|
||||
return `https://player.vimeo.com/video/${safeId}?autoplay=1`;
|
||||
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 };
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user