diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index 72f1f62..c29628e 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -24,7 +24,7 @@ import {
UnsupportedContent,
VideoContent,
} from './message';
-import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
+import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -65,12 +65,26 @@ export function RenderMessageContent({
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
if (filteredUrls.length === 0) return undefined;
+
+ // Separate YouTube URLs from other URLs
+ const youtubeUrls = filteredUrls.filter(isYouTubeUrl);
+ const otherUrls = filteredUrls.filter((url) => !isYouTubeUrl(url));
+
return (
-
- {filteredUrls.map((url) => (
-
+ <>
+ {/* Render YouTube embeds (ad-free via Piped) */}
+ {youtubeUrls.map((url) => (
+
))}
-
+ {/* Render standard URL previews for other links */}
+ {otherUrls.length > 0 && (
+
+ {otherUrls.map((url) => (
+
+ ))}
+
+ )}
+ >
);
};
const renderCaption = () => {
diff --git a/src/app/components/url-preview/YouTubeEmbed.css.tsx b/src/app/components/url-preview/YouTubeEmbed.css.tsx
new file mode 100644
index 0000000..c254b7f
--- /dev/null
+++ b/src/app/components/url-preview/YouTubeEmbed.css.tsx
@@ -0,0 +1,95 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const YouTubeEmbed = style([
+ DefaultReset,
+ {
+ width: toRem(480),
+ maxWidth: '100%',
+ backgroundColor: color.SurfaceVariant.Container,
+ border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R300,
+ overflow: 'hidden',
+ },
+]);
+
+export const YouTubeEmbedIframe = style([
+ DefaultReset,
+ {
+ width: '100%',
+ aspectRatio: '16 / 9',
+ border: 'none',
+ display: 'block',
+ },
+]);
+
+export const YouTubeEmbedHeader = style([
+ DefaultReset,
+ {
+ padding: `${config.space.S100} ${config.space.S200}`,
+ backgroundColor: color.SurfaceVariant.ContainerActive,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: config.space.S200,
+ },
+]);
+
+export const YouTubeEmbedLink = style({
+ color: color.Success.Main,
+ textDecoration: 'none',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ ':hover': {
+ textDecoration: 'underline',
+ },
+});
+
+export const YouTubeThumbnailButton = style([
+ DefaultReset,
+ {
+ position: 'relative',
+ width: '100%',
+ aspectRatio: '16 / 9',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ backgroundColor: '#000',
+ display: 'block',
+ },
+]);
+
+export const YouTubeThumbnail = style([
+ DefaultReset,
+ {
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ display: 'block',
+ },
+]);
+
+export const YouTubePlayButton = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: toRem(68),
+ height: toRem(48),
+ backgroundColor: 'rgba(255, 0, 0, 0.9)',
+ borderRadius: toRem(12),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: '#fff',
+ transition: 'background-color 0.2s ease',
+ selectors: {
+ [`${YouTubeThumbnailButton}:hover &`]: {
+ backgroundColor: 'rgba(255, 0, 0, 1)',
+ },
+ },
+ },
+]);
diff --git a/src/app/components/url-preview/YouTubeEmbed.tsx b/src/app/components/url-preview/YouTubeEmbed.tsx
new file mode 100644
index 0000000..88c4957
--- /dev/null
+++ b/src/app/components/url-preview/YouTubeEmbed.tsx
@@ -0,0 +1,217 @@
+import React, { MouseEvent, useCallback, useState } from 'react';
+import { Box, Icon, Icons, Spinner, Text, as } from 'folds';
+import * as css from './YouTubeEmbed.css';
+import { getYouTubeStream, isYouTubeStreamingAvailable, openExternalUrl } from '../../utils/tauri';
+
+/**
+ * Regex patterns to match YouTube URLs and extract video IDs
+ */
+const YOUTUBE_URL_PATTERNS = [
+ // Standard youtube.com/watch?v=VIDEO_ID
+ /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?(?:.*&)?v=([a-zA-Z0-9_-]{11})(?:&|$)/,
+ // Short youtu.be/VIDEO_ID
+ /(?:https?:\/\/)?(?:www\.)?youtu\.be\/([a-zA-Z0-9_-]{11})(?:\?|$|\/)?/,
+ // Embedded youtube.com/embed/VIDEO_ID
+ /(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?|$)/,
+ // Shorts youtube.com/shorts/VIDEO_ID
+ /(?:https?:\/\/)?(?:www\.)?youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})(?:\?|$)/,
+ // YouTube Music music.youtube.com/watch?v=VIDEO_ID
+ /(?:https?:\/\/)?music\.youtube\.com\/watch\?(?:.*&)?v=([a-zA-Z0-9_-]{11})(?:&|$)/,
+];
+
+/**
+ * YouTube embed URL base - fallback when yt-dlp is not available
+ */
+const YOUTUBE_EMBED_BASE = 'https://www.youtube.com/embed';
+
+/**
+ * Get YouTube thumbnail URL for a video
+ * @param videoId The YouTube video ID
+ * @returns The thumbnail URL (high quality)
+ */
+function getYouTubeThumbnailUrl(videoId: string): string {
+ return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
+}
+
+/**
+ * Checks if a URL is a YouTube URL
+ * @param url The URL to check
+ * @returns True if the URL is a YouTube URL
+ */
+export function isYouTubeUrl(url: string): boolean {
+ return YOUTUBE_URL_PATTERNS.some((pattern) => pattern.test(url));
+}
+
+/**
+ * Extracts the video ID from a YouTube URL
+ * @param url The YouTube URL
+ * @returns The video ID or null if not found
+ */
+export function extractYouTubeVideoId(url: string): string | null {
+ let videoId: string | null = null;
+ YOUTUBE_URL_PATTERNS.some((pattern) => {
+ const match = url.match(pattern);
+ if (match) {
+ const [, id] = match;
+ if (id) {
+ videoId = id;
+ return true;
+ }
+ }
+ return false;
+ });
+ return videoId;
+}
+
+/**
+ * Generates a YouTube embed URL for a video (fallback)
+ * @param videoId The YouTube video ID
+ * @returns The embed URL
+ */
+export function getYouTubeEmbedUrl(videoId: string): string {
+ return `${YOUTUBE_EMBED_BASE}/${videoId}?autoplay=1`;
+}
+
+/**
+ * Click handler for external links - uses Tauri shell on desktop/mobile
+ */
+const handleExternalLinkClick = (e: MouseEvent) => {
+ const { href } = e.currentTarget;
+ if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
+ e.preventDefault();
+ openExternalUrl(href);
+ }
+};
+
+type PlayerState =
+ | { status: 'thumbnail' }
+ | { status: 'loading' }
+ | { status: 'playing'; videoUrl: string; title: string }
+ | { status: 'fallback-iframe' };
+
+type YouTubeEmbedProps = {
+ /** The original YouTube URL */
+ url: string;
+};
+
+/**
+ * YouTube embed component that shows thumbnail preview and uses yt-dlp
+ * for direct ad-free streaming when clicked
+ */
+export const YouTubeEmbed = as<'div', YouTubeEmbedProps>(({ url, ...props }, ref) => {
+ const videoId = extractYouTubeVideoId(url);
+ const [playerState, setPlayerState] = useState({ status: 'thumbnail' });
+
+ const handlePlay = useCallback(async () => {
+ if (!isYouTubeStreamingAvailable()) {
+ setPlayerState({ status: 'fallback-iframe' });
+ return;
+ }
+
+ setPlayerState({ status: 'loading' });
+
+ try {
+ const info = await getYouTubeStream(url);
+ setPlayerState({ status: 'playing', videoUrl: info.video_url, title: info.title });
+ } catch {
+ setPlayerState({ status: 'fallback-iframe' });
+ }
+ }, [url]);
+
+ if (!videoId) {
+ return null;
+ }
+
+ const thumbnailUrl = getYouTubeThumbnailUrl(videoId);
+
+ const renderHeader = (label: string) => (
+
+
+ {label}
+
+
+ );
+
+ // Thumbnail preview - click to play
+ if (playerState.status === 'thumbnail') {
+ return (
+
+ {renderHeader('YouTube')}
+
+
+ );
+ }
+
+ // Loading state
+ if (playerState.status === 'loading') {
+ return (
+
+ {renderHeader('YouTube - Loading...')}
+
+
+
+
+ );
+ }
+
+ // Direct stream via yt-dlp (ad-free)
+ if (playerState.status === 'playing') {
+ return (
+
+ {renderHeader(`${playerState.title} (ad-free)`)}
+
+
+ );
+ }
+
+ // Fallback to YouTube iframe
+ const embedUrl = getYouTubeEmbedUrl(videoId);
+ return (
+
+ {renderHeader('YouTube')}
+
+
+ );
+});
diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts
index 90dc2ff..f61a1b4 100644
--- a/src/app/components/url-preview/index.ts
+++ b/src/app/components/url-preview/index.ts
@@ -1,2 +1,3 @@
export * from './UrlPreview';
export * from './UrlPreviewCard';
+export * from './YouTubeEmbed';
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 322f925..809b2a5 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -1060,10 +1060,18 @@ function Messages() {
/>
- }
- />
+ }
+ />
+
+
+ For ad-free YouTube streaming, install yt-dlp (required for direct playback):
+
+
+ winget install yt-dlp
+
+
=> {
console.log('[openExternalUrl] falling back to window.open');
window.open(url, '_blank');
};
+
+/**
+ * YouTube stream info returned by yt-dlp
+ */
+export interface YouTubeStreamInfo {
+ /** Direct video stream URL */
+ video_url: string;
+ /** Title of the video */
+ title: string;
+}
+
+/**
+ * Get direct YouTube stream URL using yt-dlp
+ * Requires yt-dlp to be installed on the system
+ * @param url The YouTube video URL
+ * @returns Stream info with direct URL and title
+ * @throws If yt-dlp is not installed or fails
+ */
+export const getYouTubeStream = async (url: string): Promise => {
+ if (typeof window !== 'undefined' && (window as any).__TAURI__) {
+ const { invoke } = await import('@tauri-apps/api/core');
+ return invoke('get_youtube_stream', { url });
+ }
+ throw new Error('YouTube streaming requires Tauri desktop app with yt-dlp installed');
+};
+
+/**
+ * Check if yt-dlp is available for YouTube streaming
+ * @returns True if yt-dlp streaming is available
+ */
+export const isYouTubeStreamingAvailable = (): boolean => {
+ return typeof window !== 'undefined' && !!(window as any).__TAURI__;
+};
+
/**
* Tauri-specific utilities for desktop and mobile platforms
*/