From c55e3112415c25f664bd43d37c941c94dca0a144 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Fri, 20 Feb 2026 16:27:59 +1100 Subject: [PATCH] feat(youtube): add YouTube embed support with yt-dlp integration for ad-free streaming --- src/app/components/RenderMessageContent.tsx | 24 +- .../url-preview/YouTubeEmbed.css.tsx | 95 ++++++++ .../components/url-preview/YouTubeEmbed.tsx | 217 ++++++++++++++++++ src/app/components/url-preview/index.ts | 1 + src/app/features/settings/general/General.tsx | 16 +- src/app/utils/tauri.ts | 34 +++ 6 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 src/app/components/url-preview/YouTubeEmbed.css.tsx create mode 100644 src/app/components/url-preview/YouTubeEmbed.tsx 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')} +