diff --git a/src/components/VideoPlayerWithChapters.tsx b/src/components/VideoPlayerWithChapters.tsx index a2034d5..07da05c 100644 --- a/src/components/VideoPlayerWithChapters.tsx +++ b/src/components/VideoPlayerWithChapters.tsx @@ -1,24 +1,4 @@ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; -import Plyr from 'plyr'; -import 'plyr/dist/plyr.css'; - -// YouTube Player API types -declare global { - interface Window { - YT?: { - Player: any; - PlayerState: { - UNSTARTED: number; - ENDED: number; - PLAYING: number; - PAUSED: number; - BUFFERING: number; - VIDEO_CUED: number; - }; - }; - onYouTubeIframeAPIReady?: () => void; - } -} interface VideoChapter { time: number; // Time in seconds @@ -49,16 +29,10 @@ export const VideoPlayerWithChapters = forwardRef { - const videoRef = useRef(null); - const wrapperRef = useRef(null); - const playerRef = useRef(null); - const posterRef = useRef(null); - const ytPlayerRef = useRef(null); + const iframeRef = useRef(null); const intervalRef = useRef(null); const [currentChapterIndex, setCurrentChapterIndex] = useState(-1); - const [isPlaying, setIsPlaying] = useState(false); - const [ytPlayerState, setYtPlayerState] = useState(-1); // YouTube player state - const [ytApiReady, setYtApiReady] = useState(false); + const [currentTime, setCurrentTime] = useState(0); // Detect if this is a YouTube URL const isYouTube = videoUrl && ( @@ -82,181 +56,107 @@ export const VideoPlayerWithChapters = forwardRef { - if (window.YT && window.YT.Player) { - setYtApiReady(true); - return; - } + if (!accentColor || !isYouTube) return; - const tag = document.createElement('script'); - tag.src = 'https://www.youtube.com/iframe_api'; - document.body.appendChild(tag); - - window.onYouTubeIframeAPIReady = () => setYtApiReady(true); + const style = document.createElement('style'); + style.textContent = ` + .youtube-wrapper { + position: relative; + padding-bottom: 56.25%; + height: 0; + overflow: hidden; + border-radius: 0.5rem; + } + .youtube-wrapper iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + `; + document.head.appendChild(style); return () => { - window.onYouTubeIframeAPIReady = undefined; + document.head.removeChild(style); }; - }, []); + }, [accentColor, isYouTube]); - // Initialize Plyr with YouTube API integration + // Time tracking using postMessage (YouTube IFrame API without loading the script) useEffect(() => { - if (!wrapperRef.current || !isYouTube) return; + if (!isYouTube || !iframeRef.current || chapters.length === 0) return; - // Initialize Plyr - const player = new Plyr(wrapperRef.current, { - youtube: { - noCookie: true, - rel: 0, - showinfo: 0, - iv_load_policy: 3, - modestbranding: 1, - controls: 0, - customControls: true, - }, - controls: [ - 'play-large', - 'play', - 'progress', - 'current-time', - 'duration', - 'mute', - 'volume', - 'settings', - 'pip', - 'airplay', - 'fullscreen', - ], - settings: ['quality', 'speed'], - keyboard: { - global: true, - }, - }); + // Listen for time updates from YouTube iframe + const handleMessage = (event: MessageEvent) => { + // Verify the message is from YouTube + if (!event.origin.includes('youtube.com') && !event.origin.includes('googlevideo.com')) { + return; + } - playerRef.current = player; + const data = event.data; + if (typeof data === 'string') { + try { + const parsed = JSON.parse(data); + if (parsed.event === 'infoDelivery' && parsed.info && parsed.info.currentTime) { + const time = parsed.info.currentTime; + setCurrentTime(time); - // Wait for YouTube API to be ready and then initialize YT player - const initializeYouTubePlayer = () => { - if (!ytApiReady || !wrapperRef.current) return; - - const iframe = wrapperRef.current.querySelector('iframe'); - if (!iframe) return; - - // Create YouTube Player instance - const ytPlayer = new window.YT.Player(iframe, { - events: { - onReady: () => { - // Set up time tracking for chapters using Plyr's time - intervalRef.current = setInterval(() => { - if (playerRef.current) { - const currentTime = playerRef.current.currentTime; - if (onTimeUpdate && currentTime > 0) { - onTimeUpdate(currentTime); - } - - // Find current chapter - if (chapters.length > 0) { - 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]); - } - } - } - } - }, 500); - }, - onStateChange: (event: any) => { - const state = event.data; - setYtPlayerState(state); - - // -1 = unstarted, 0 = ended, 1 = playing, 2 = paused, 3 = buffering, 5 = video cued - if (state === 1) { - setIsPlaying(true); - } else if (state === 2 || state === 0) { - setIsPlaying(false); + if (onTimeUpdate) { + onTimeUpdate(time); } - }, - }, - }); - ytPlayerRef.current = ytPlayer; + // Find current chapter + let index = chapters.findIndex((chapter, i) => { + const nextChapter = chapters[i + 1]; + return time >= chapter.time && (!nextChapter || time < nextChapter.time); + }); + + // If before first chapter, no active chapter + if (index === -1 && time < chapters[0].time) { + index = -1; + } + + if (index !== currentChapterIndex) { + setCurrentChapterIndex(index); + if (index >= 0 && onChapterChange) { + onChapterChange(chapters[index]); + } + } + } + } catch (e) { + // Ignore parsing errors + } + } }; - // Initialize YouTube player when API is ready - if (ytApiReady) { - initializeYouTubePlayer(); - } - - // 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; - } - /* Hide YouTube's native play button */ - .ytp-large-play-button { - display: none !important; - } - .html5-video-player { - background: #000; - } - `; - document.head.appendChild(style); - - return () => { - document.head.removeChild(style); - }; - } + window.addEventListener('message', handleMessage); return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - if (ytPlayerRef.current && ytPlayerRef.current.destroy) { - ytPlayerRef.current.destroy(); - } - player.destroy(); + window.removeEventListener('message', handleMessage); }; - }, [isYouTube, ytApiReady, accentColor]); + }, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]); - // Prevent context menu and unwanted interactions - const preventAction = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - // Jump to specific time + // Jump to specific time using postMessage const jumpToTime = (time: number) => { - if (playerRef.current) { - playerRef.current.currentTime = time; - playerRef.current.play(); + if (iframeRef.current && iframeRef.current.contentWindow) { + // Send seek command to YouTube iframe + iframeRef.current.contentWindow.postMessage( + `{"event":"command","func":"seekTo","args":[${time}, true]}`, + 'https://www.youtube.com' + ); + // Auto-play after seeking + iframeRef.current.contentWindow.postMessage( + `{"event":"command","func":"playVideo","args":[]}`, + 'https://www.youtube.com' + ); } }; const getCurrentTime = () => { - return playerRef.current ? playerRef.current.currentTime : 0; + return currentTime; }; // Expose methods via ref @@ -278,112 +178,15 @@ export const VideoPlayerWithChapters = forwardRef +
{youtubeId && ( - <> -