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 { 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) => { 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({ status: 'thumbnail' }); const [videoTitle, setVideoTitle] = useState(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) => (
{label}
); // 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 ( {renderHeader(getHeaderLabel())} ); } // Loading state if (playerState.status === 'loading') { return ( {renderHeader(getHeaderLabel('- Loading...'))} ); } // Direct stream via yt-dlp (ad-free) if (playerState.status === 'playing') { return ( {renderHeader(`${playerState.title} (ad-free)`)} ); } // Fallback to YouTube iframe const embedUrl = getYouTubeEmbedUrl(videoId); return ( {renderHeader(getHeaderLabel())}