264 lines
7.4 KiB
TypeScript
264 lines
7.4 KiB
TypeScript
import React, { MouseEvent, useCallback, useEffect, 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';
|
|
|
|
/**
|
|
* YouTube oEmbed API base - used to fetch video metadata
|
|
*/
|
|
const YOUTUBE_OEMBED_API = 'https://www.youtube.com/oembed';
|
|
|
|
/**
|
|
* 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`;
|
|
}
|
|
|
|
/**
|
|
* Fetch YouTube video title using oEmbed API
|
|
* @param url The YouTube video URL
|
|
* @returns The video title or null if fetch fails
|
|
*/
|
|
async function fetchYouTubeTitle(url: string): Promise<string | null> {
|
|
try {
|
|
const oembedUrl = `${YOUTUBE_OEMBED_API}?url=${encodeURIComponent(url)}&format=json`;
|
|
const response = await fetch(oembedUrl);
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
return data.title || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 [videoTitle, setVideoTitle] = useState<string | null>(null);
|
|
|
|
// Fetch video title on mount
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
fetchYouTubeTitle(url).then((title) => {
|
|
if (!cancelled) {
|
|
setVideoTitle(title);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [url]);
|
|
|
|
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>
|
|
);
|
|
|
|
// Determine what to show in the header based on video title availability
|
|
const getHeaderLabel = (suffix?: string) => {
|
|
if (videoTitle) {
|
|
return suffix ? `${videoTitle} ${suffix}` : videoTitle;
|
|
}
|
|
return suffix ? `YouTube ${suffix}` : 'YouTube';
|
|
};
|
|
|
|
// Thumbnail preview - click to play
|
|
if (playerState.status === 'thumbnail') {
|
|
return (
|
|
<Box shrink="No" className={css.YouTubeEmbed} direction="Column" {...props} ref={ref}>
|
|
{renderHeader(getHeaderLabel())}
|
|
<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(getHeaderLabel('- 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(getHeaderLabel())}
|
|
<iframe
|
|
className={css.YouTubeEmbedIframe}
|
|
src={embedUrl}
|
|
title="YouTube video player"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
</Box>
|
|
);
|
|
});
|