Files
meet-hub/src/components/VideoPlayerWithChapters.tsx
dwindown 0df57bbac5 Fix overlay to block YouTube UI when video is paused
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>
2026-01-01 01:28:27 +07:00

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>
);
});