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,
|
UnsupportedContent,
|
||||||
VideoContent,
|
VideoContent,
|
||||||
} from './message';
|
} from './message';
|
||||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
import { UrlPreviewCard, UrlPreviewHolder, YouTubeEmbed, isYouTubeUrl } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
import { PdfViewer } from './Pdf-viewer';
|
import { PdfViewer } from './Pdf-viewer';
|
||||||
@@ -65,12 +65,26 @@ export function RenderMessageContent({
|
|||||||
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||||
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
|
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
|
||||||
if (filteredUrls.length === 0) return undefined;
|
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 (
|
return (
|
||||||
<UrlPreviewHolder>
|
<>
|
||||||
{filteredUrls.map((url) => (
|
{/* Render YouTube embeds (ad-free via Piped) */}
|
||||||
<UrlPreviewCard key={url} url={url} ts={ts} />
|
{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 = () => {
|
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 './UrlPreview';
|
||||||
export * from './UrlPreviewCard';
|
export * from './UrlPreviewCard';
|
||||||
|
export * from './YouTubeEmbed';
|
||||||
|
|||||||
@@ -1060,10 +1060,18 @@ function Messages() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Url Preview"
|
title="Url Preview"
|
||||||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
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>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
|
|||||||
@@ -34,6 +34,40 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
console.log('[openExternalUrl] falling back to window.open');
|
console.log('[openExternalUrl] falling back to window.open');
|
||||||
window.open(url, '_blank');
|
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
|
* Tauri-specific utilities for desktop and mobile platforms
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user