Track playback state and show overlay with pointer-events:auto when paused, pointer-events:none when playing. This blocks YouTube UI (copy link, logo) when video is paused or initially loaded, while allowing Plyr controls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
223 lines
6.2 KiB
TypeScript
223 lines
6.2 KiB
TypeScript
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<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
|
videoUrl,
|
|
embedCode,
|
|
chapters = [],
|
|
accentColor,
|
|
onChapterChange,
|
|
onTimeUpdate,
|
|
className = '',
|
|
}, ref) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const playerRef = useRef<Plyr | null>(null);
|
|
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(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 (
|
|
<div
|
|
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
|
dangerouslySetInnerHTML={{ __html: embedCode }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={`plyr__video-embed relative ${className}`}>
|
|
{youtubeId && (
|
|
<>
|
|
{/* CSS Overlay to block YouTube UI interactions - shows when paused */}
|
|
<div
|
|
className="absolute inset-0 z-10"
|
|
style={{
|
|
background: 'transparent',
|
|
pointerEvents: isPlaying ? 'none' : 'auto'
|
|
}}
|
|
/>
|
|
<iframe
|
|
src={`https://www.youtube-nocookie.com/embed/${youtubeId}?origin=${window.location.origin}&iv_load_policy=3&modestbranding=1&playsinline=1&rel=0&showinfo=0&controls=0&disablekb=1&fs=0`}
|
|
allowFullScreen
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
style={{ pointerEvents: 'none' }}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|