diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx
index 480a4eb96..52d6d8b82 100644
--- a/src/app/components/url-preview/UrlPreview.css.tsx
+++ b/src/app/components/url-preview/UrlPreview.css.tsx
@@ -271,6 +271,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 +400,21 @@ 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',
+});
+
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx
index 7ba148479..d1882fa4d 100644
--- a/src/app/components/url-preview/UrlPreviewCard.tsx
+++ b/src/app/components/url-preview/UrlPreviewCard.tsx
@@ -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 { 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 (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Card 2: TikTok
+// Card: TikTok (fallback for short vm.tiktok.com links that can't resolve an id)
// ---------------------------------------------------------------------------
function TikTokCard({
@@ -922,22 +893,30 @@ 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 = {
+ 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 },
+};
+
+// 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 +926,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 +940,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 ? (
@@ -978,12 +970,12 @@ function VideoEmbedCard({
return (
-