feat(youtube): add YouTube embed support with yt-dlp integration for ad-free streaming
This commit is contained in:
@@ -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 (
|
||||
<UrlPreviewHolder>
|
||||
{filteredUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
<>
|
||||
{/* Render YouTube embeds (ad-free via Piped) */}
|
||||
{youtubeUrls.map((url) => (
|
||||
<YouTubeEmbed key={url} url={url} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
{/* Render standard URL previews for other links */}
|
||||
{otherUrls.length > 0 && (
|
||||
<UrlPreviewHolder>
|
||||
{otherUrls.map((url) => (
|
||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
||||
))}
|
||||
</UrlPreviewHolder>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const renderCaption = () => {
|
||||
|
||||
95
src/app/components/url-preview/YouTubeEmbed.css.tsx
Normal file
95
src/app/components/url-preview/YouTubeEmbed.css.tsx
Normal 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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
217
src/app/components/url-preview/YouTubeEmbed.tsx
Normal file
217
src/app/components/url-preview/YouTubeEmbed.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './UrlPreview';
|
||||
export * from './UrlPreviewCard';
|
||||
export * from './YouTubeEmbed';
|
||||
|
||||
@@ -1060,10 +1060,18 @@ function Messages() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Url Preview"
|
||||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||||
/>
|
||||
<SettingTile
|
||||
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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user