Implement YouTube API for accurate video time tracking and chapter navigation
Replaces Plyr-based implementation with native YouTube IFrame Player API to fix: - Accurate time tracking via getCurrentTime() polling every 500ms - Chapter jump functionality using seekTo() and playVideo() API calls - Right-click prevention with transparent overlay - Proper chapter highlighting based on current playback time Technical changes: - Load YouTube IFrame API script on component mount - Create YT.Player instance for programmatic control - Poll getCurrentTime() in interval for real-time tracking - Use getPlayerById() to retrieve player for jumpToTime operations - Add pointer-events: none overlay with context menu prevention - Generate unique iframe IDs for proper API targeting This approach balances working timeline/jump functionality with preventing direct URL access via overlay blocking right-click context menus. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,9 +30,9 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
className = '',
|
className = '',
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isApiReady, setIsApiReady] = useState(false);
|
||||||
|
|
||||||
// Detect if this is a YouTube URL
|
// Detect if this is a YouTube URL
|
||||||
const isYouTube = videoUrl && (
|
const isYouTube = videoUrl && (
|
||||||
@@ -56,59 +56,71 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
|
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
|
||||||
const useEmbed = !isYouTube && embedCode;
|
const useEmbed = !isYouTube && embedCode;
|
||||||
|
|
||||||
// Apply custom accent color via CSS
|
// Load YouTube IFrame API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accentColor || !isYouTube) return;
|
if (!isYouTube) return;
|
||||||
|
|
||||||
const style = document.createElement('style');
|
// Check if API is already loaded
|
||||||
style.textContent = `
|
if ((window as any).YT && (window as any).YT.Player) {
|
||||||
.youtube-wrapper {
|
setIsApiReady(true);
|
||||||
position: relative;
|
return;
|
||||||
padding-bottom: 56.25%;
|
}
|
||||||
height: 0;
|
|
||||||
overflow: hidden;
|
// Load YouTube IFrame API
|
||||||
border-radius: 0.5rem;
|
const tag = document.createElement('script');
|
||||||
}
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
.youtube-wrapper iframe {
|
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||||
position: absolute;
|
if (firstScriptTag && firstScriptTag.parentNode) {
|
||||||
top: 0;
|
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
|
||||||
left: 0;
|
} else {
|
||||||
width: 100%;
|
document.body.appendChild(tag);
|
||||||
height: 100%;
|
}
|
||||||
border: none;
|
|
||||||
}
|
// Set up callback
|
||||||
`;
|
(window as any).onYouTubeIframeAPIReady = () => {
|
||||||
document.head.appendChild(style);
|
setIsApiReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.head.removeChild(style);
|
(window as any).onYouTubeIframeAPIReady = null;
|
||||||
};
|
};
|
||||||
}, [accentColor, isYouTube]);
|
}, [isYouTube]);
|
||||||
|
|
||||||
// Time tracking using postMessage (YouTube IFrame API without loading the script)
|
// Initialize YouTube player and set up tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isYouTube || !iframeRef.current || chapters.length === 0) return;
|
if (!isYouTube || !isApiReady || !iframeRef.current) return;
|
||||||
|
|
||||||
// Listen for time updates from YouTube iframe
|
// Wait for iframe to be ready
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const initializePlayer = () => {
|
||||||
// Verify the message is from YouTube
|
if (!iframeRef.current || !(window as any).YT) return;
|
||||||
if (!event.origin.includes('youtube.com') && !event.origin.includes('googlevideo.com')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = event.data;
|
const youtubeId = getYouTubeId(effectiveVideoUrl);
|
||||||
if (typeof data === 'string') {
|
if (!youtubeId) return;
|
||||||
|
|
||||||
|
// Create YouTube player instance
|
||||||
|
const player = new (window as any).YT.Player(iframeRef.current, {
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
console.log('YouTube player ready');
|
||||||
|
},
|
||||||
|
onStateChange: (event: any) => {
|
||||||
|
// Player state changed
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up time tracking interval
|
||||||
|
const interval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
const time = player.getCurrentTime();
|
||||||
if (parsed.event === 'infoDelivery' && parsed.info && parsed.info.currentTime) {
|
setCurrentTime(time);
|
||||||
const time = parsed.info.currentTime;
|
|
||||||
setCurrentTime(time);
|
|
||||||
|
|
||||||
if (onTimeUpdate) {
|
if (onTimeUpdate) {
|
||||||
onTimeUpdate(time);
|
onTimeUpdate(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find current chapter
|
// Find current chapter
|
||||||
|
if (chapters.length > 0) {
|
||||||
let index = chapters.findIndex((chapter, i) => {
|
let index = chapters.findIndex((chapter, i) => {
|
||||||
const nextChapter = chapters[i + 1];
|
const nextChapter = chapters[i + 1];
|
||||||
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
return time >= chapter.time && (!nextChapter || time < nextChapter.time);
|
||||||
@@ -127,32 +139,51 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore parsing errors
|
// Player not ready yet, ignore
|
||||||
}
|
}
|
||||||
}
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
if (player && player.destroy) {
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
const timeout = setTimeout(initializePlayer, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', handleMessage);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
}, [isYouTube, isApiReady, effectiveVideoUrl, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
||||||
|
|
||||||
// Jump to specific time using postMessage
|
// Jump to specific time using YouTube API
|
||||||
const jumpToTime = (time: number) => {
|
const jumpToTime = (time: number) => {
|
||||||
if (iframeRef.current && iframeRef.current.contentWindow) {
|
const iframe = iframeRef.current;
|
||||||
// Send seek command to YouTube iframe
|
if (!iframe || !iframe.contentWindow) return;
|
||||||
iframeRef.current.contentWindow.postMessage(
|
|
||||||
`{"event":"command","func":"seekTo","args":[${time}, true]}`,
|
// Try YouTube API first
|
||||||
'https://www.youtube.com'
|
if (isApiReady && (window as any).YT && (window as any).YT.getPlayerById) {
|
||||||
);
|
const player = (window as any).YT.getPlayerById(iframe.id);
|
||||||
// Auto-play after seeking
|
if (player) {
|
||||||
iframeRef.current.contentWindow.postMessage(
|
player.seekTo(time, true);
|
||||||
|
player.playVideo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use postMessage
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
|
`{"event":"command","func":"seekTo","args":[${time}, true]}`,
|
||||||
|
'https://www.youtube.com'
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
iframe.contentWindow.postMessage(
|
||||||
`{"event":"command","func":"playVideo","args":[]}`,
|
`{"event":"command","func":"playVideo","args":[]}`,
|
||||||
'https://www.youtube.com'
|
'https://www.youtube.com'
|
||||||
);
|
);
|
||||||
}
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentTime = () => {
|
const getCurrentTime = () => {
|
||||||
@@ -178,15 +209,34 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`youtube-wrapper ${className}`}>
|
<div className={`relative ${className}`} style={{ paddingBottom: '56.25%', height: 0 }}>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.youtube-iframe-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
{youtubeId && (
|
{youtubeId && (
|
||||||
<iframe
|
<>
|
||||||
ref={iframeRef}
|
<iframe
|
||||||
src={`https://www.youtube-nocookie.com/embed/${youtubeId}?rel=0&modestbranding=1&iv_load_policy=3&showinfo=0&controls=1&enablejsapi=1`}
|
ref={iframeRef}
|
||||||
title="YouTube video player"
|
id={`youtube-${youtubeId}-${Math.random().toString(36).substr(2, 9)}`}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
src={`https://www.youtube-nocookie.com/embed/${youtubeId}?rel=0&modestbranding=1&iv_load_policy=3&showinfo=0&controls=1&enablejsapi=1&widgetid=1`}
|
||||||
allowFullScreen
|
title="YouTube video player"
|
||||||
/>
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowFullScreen
|
||||||
|
className="absolute top-0 left-0 w-full h-full rounded-lg"
|
||||||
|
/>
|
||||||
|
{/* Transparent overlay to prevent right-click and URL copying */}
|
||||||
|
<div className="youtube-iframe-overlay" onContextMenu={(e) => e.preventDefault()} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user