Files
cinny/src/app/components/url-preview/YouTubeEmbed.tsx

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>
);
});