feat(embeds): inline YouTube/Vimeo players + media-forward video tiles
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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user