import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import Plyr from 'plyr'; import 'plyr/dist/plyr.css'; interface VideoChapter { time: number; // Time in seconds title: string; } interface VideoPlayerWithChaptersProps { videoUrl: string; embedCode?: string | null; chapters?: VideoChapter[]; accentColor?: string; onChapterChange?: (chapter: VideoChapter) => void; onTimeUpdate?: (time: number) => void; className?: string; } export interface VideoPlayerRef { jumpToTime: (time: number) => void; getCurrentTime: () => number; } export const VideoPlayerWithChapters = forwardRef(({ videoUrl, embedCode, chapters = [], accentColor, onChapterChange, onTimeUpdate, className = '', }, ref) => { const videoRef = useRef(null); const wrapperRef = useRef(null); const playerRef = useRef(null); const [currentChapterIndex, setCurrentChapterIndex] = useState(-1); const [isPlaying, setIsPlaying] = useState(false); // Detect if this is a YouTube URL const isYouTube = videoUrl && ( videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be') ); // Get YouTube video ID const getYouTubeId = (url: string): string | null => { const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s/]+)/); return match ? match[1] : null; }; // Convert embed code to YouTube URL if possible const getYouTubeUrlFromEmbed = (embed: string): string | null => { const match = embed.match(/src=["'](?:https?:)?\/\/(?:www\.)?youtube\.com\/embed\/([^"'\s?]*)/); return match ? `https://www.youtube.com/watch?v=${match[1]}` : null; }; // Determine which video source to use const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl); const useEmbed = !isYouTube && embedCode; useEffect(() => { if (!wrapperRef.current) return; // Initialize Plyr const player = new Plyr(wrapperRef.current, { youtube: { noCookie: true, rel: 0, showinfo: 0, iv_load_policy: 3, modestbranding: 1, controls: 0, }, controls: [ 'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'settings', 'pip', 'airplay', 'fullscreen', ], settings: ['quality', 'speed'], keyboard: { global: true, }, }); playerRef.current = player; // Track play/pause state to show/hide overlay player.on('play', () => setIsPlaying(true)); player.on('pause', () => setIsPlaying(false)); player.on('ended', () => setIsPlaying(false)); // Apply custom accent color if (accentColor) { const style = document.createElement('style'); style.textContent = ` .plyr--full-ui input[type=range] { color: ${accentColor} !important; } .plyr__control--overlaid, .plyr__controls .plyr__control.plyr__tab-focus, .plyr__controls .plyr__control:hover, .plyr__controls .plyr__control[aria-expanded=true] { background: ${accentColor} !important; } .plyr__progress__buffer { color: ${accentColor}40 !important; } `; document.head.appendChild(style); return () => { document.head.removeChild(style); }; } return () => { player.destroy(); }; }, [accentColor]); // Handle chapter tracking useEffect(() => { if (!playerRef.current || chapters.length === 0) return; const player = playerRef.current; const updateTime = () => { const currentTime = player.currentTime; // Report time update to parent if (onTimeUpdate) { onTimeUpdate(currentTime); } // Find current chapter let index = chapters.findIndex((chapter, i) => { const nextChapter = chapters[i + 1]; return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time); }); // If before first chapter, no active chapter if (index === -1 && currentTime < chapters[0].time) { index = -1; } if (index !== currentChapterIndex) { setCurrentChapterIndex(index); if (index >= 0 && onChapterChange) { onChapterChange(chapters[index]); } } }; player.on('timeupdate', updateTime); return () => { player.off('timeupdate', updateTime); }; }, [chapters, currentChapterIndex, onChapterChange, onTimeUpdate]); // Jump to specific time const jumpToTime = (time: number) => { if (playerRef.current && isYouTube) { playerRef.current.currentTime = time; playerRef.current.play(); } }; const getCurrentTime = () => { return playerRef.current ? playerRef.current.currentTime : 0; }; // Expose methods via ref useImperativeHandle(ref, () => ({ jumpToTime, getCurrentTime, })); if (useEmbed) { // Custom embed (Adilo, Vimeo, etc.) return (
); } const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null; return (
{youtubeId && ( <> {/* CSS Overlay to block YouTube UI interactions - shows when paused */}