feat(youtube): add YouTube embed support with yt-dlp integration for ad-free streaming

This commit is contained in:
2026-02-20 16:27:59 +11:00
parent d5ca19f770
commit c55e311241
6 changed files with 378 additions and 9 deletions

View File

@@ -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 (
<>
{/* Render YouTube embeds (ad-free via Piped) */}
{youtubeUrls.map((url) => (
<YouTubeEmbed key={url} url={url} />
))}
{/* Render standard URL previews for other links */}
{otherUrls.length > 0 && (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
{otherUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
)}
</>
);
};
const renderCaption = () => {

View File

@@ -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)',
},
},
},
]);

View File

@@ -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<HTMLAnchorElement>) => {
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<PlayerState>({ 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) => (
<div className={css.YouTubeEmbedHeader}>
<Text
as="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="T200"
className={css.YouTubeEmbedLink}
onClick={handleExternalLinkClick}
truncate
>
{label}
</Text>
</div>
);
// Thumbnail preview - click to play
if (playerState.status === 'thumbnail') {
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube')}
<button
type="button"
className={css.YouTubeThumbnailButton}
onClick={handlePlay}
aria-label="Play video"
>
<img
src={thumbnailUrl}
alt="Video thumbnail"
className={css.YouTubeThumbnail}
/>
<div className={css.YouTubePlayButton}>
<Icon size="600" src={Icons.Play} />
</div>
</button>
</Box>
);
}
// Loading state
if (playerState.status === 'loading') {
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube - Loading...')}
<Box
className={css.YouTubeEmbedIframe}
alignItems="Center"
justifyContent="Center"
style={{ background: '#000', display: 'flex' }}
>
<Spinner variant="Secondary" size="600" />
</Box>
</Box>
);
}
// Direct stream via yt-dlp (ad-free)
if (playerState.status === 'playing') {
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader(`${playerState.title} (ad-free)`)}
<video
className={css.YouTubeEmbedIframe}
src={playerState.videoUrl}
controls
autoPlay
preload="metadata"
>
<track kind="captions" />
</video>
</Box>
);
}
// Fallback to YouTube iframe
const embedUrl = getYouTubeEmbedUrl(videoId);
return (
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
{renderHeader('YouTube')}
<iframe
className={css.YouTubeEmbedIframe}
src={embedUrl}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</Box>
);
});

View File

@@ -1,2 +1,3 @@
export * from './UrlPreview';
export * from './UrlPreviewCard';
export * from './YouTubeEmbed';

View File

@@ -1064,6 +1064,14 @@ function Messages() {
title="Url Preview"
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/>
<Box style={{ marginLeft: '2.5rem', marginTop: '0.5rem', marginBottom: '1rem', background: 'rgba(255,255,255,0.045)', borderRadius: '8px', padding: '1.1rem 1.3rem', maxWidth: '36rem', boxShadow: '0 1px 4px 0 rgba(0,0,0,0.07)' }}>
<Text size="T200" style={{ color: '#d0d0d0', display: 'block', marginBottom: '0.5rem', lineHeight: 1.6, fontWeight: 400 }}>
For ad-free YouTube streaming, install <b>yt-dlp</b> (required for direct playback):
</Text>
<Box as="pre" style={{ background: '#232323', color: '#fff', borderRadius: '5px', padding: '0.6rem 1rem', fontSize: '1.08em', margin: 0, fontFamily: 'JetBrains Mono, Fira Mono, Menlo, monospace', overflowX: 'auto', width: '100%', textAlign: 'center', letterSpacing: '0.01em' }}>
winget install yt-dlp
</Box>
</Box>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile

View File

@@ -34,6 +34,40 @@ export const openExternalUrl = async (url: string): Promise<void> => {
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<YouTubeStreamInfo> => {
if (typeof window !== 'undefined' && (window as any).__TAURI__) {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<YouTubeStreamInfo>('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
*/