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>
This commit is contained in:
2026-07-06 19:48:12 -04:00
parent 29d74eda8f
commit 93f307cd63
6 changed files with 393 additions and 210 deletions
+148 -210
View File
@@ -17,6 +17,16 @@ 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 {
buildVideoEmbedUrl,
getVimeoVideoId,
getYouTubeVideoId,
getYoutubeShortsId,
isYouTubeShorts,
VideoEmbedProvider,
} from '../../utils/videoEmbed';
const linkStyles = { color: color.Success.Main };
@@ -44,53 +54,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 +74,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);
@@ -483,153 +434,21 @@ 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>
</>
<VideoEmbedCard
url={url}
prev={prev}
provider="youtube"
videoId={getYoutubeShortsId(url) ?? ''}
portrait
siteBadgeLabel="Shorts"
siteBadgeClass={previewCss.BadgeYouTubeShorts}
/>
);
}
@@ -1098,15 +917,136 @@ 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.
function VideoEmbedCard({
url,
prev,
provider,
videoId,
portrait,
siteBadgeLabel,
siteBadgeClass,
}: {
url: string;
prev: IPreviewUrlResponse;
provider: VideoEmbedProvider;
videoId: string;
portrait?: boolean;
siteBadgeLabel: string;
siteBadgeClass: string;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(
mx,
mxcImage,
useAuthentication,
portrait ? 320 : 480,
portrait ? 568 : 270,
'scale',
false,
)
: undefined;
const mediaClass = portrait ? previewCss.EmbedMediaPortrait : previewCss.EmbedMediaLandscape;
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>
);
return (
<VideoCard
<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
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.EmbedFacade}
aria-label={`Watch on ${siteBadgeLabel}: ${title}`}
>
{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 && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
</UrlPreviewContent>
</Box>
);
}
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
return (
<VideoEmbedCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
provider="youtube"
videoId={getYouTubeVideoId(url) ?? ''}
siteBadgeLabel="YouTube"
siteBadgeClass=""
/>
@@ -1114,14 +1054,12 @@ function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
}
function VimeoCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
// Vimeo always includes og:image for video thumbnails
const thumbnailSrc = (prev['og:image'] as string | undefined) ?? '';
return (
<VideoCard
<VideoEmbedCard
url={url}
prev={prev}
thumbnailUrl={thumbnailSrc}
provider="vimeo"
videoId={getVimeoVideoId(url) ?? ''}
siteBadgeLabel="Vimeo"
siteBadgeClass={previewCss.BadgeVimeo}
/>