Implement YouTube UI blocking with strategic overlays
Based on web research, implemented a comprehensive solution to prevent users from accessing YouTube UI elements and copying/sharing video URLs. Changes: - Load YouTube IFrame Player API to track player state accurately - Create YT Player instance to detect play/pause/end events - Add strategic overlays to block specific YouTube UI elements: - Top overlay (60px) - blocks "Copy Link" and title (always on) - Bottom right overlay - blocks YouTube logo (always on) - Bottom left overlay - blocks "Watch on YouTube" (before start) - Center overlay - blocks "More Videos" (when paused) - Large overlay - blocks related videos wall (when ended) - Add sandbox attribute to iframe to prevent popups - Track player state: -1=unstarted, 0=ended, 1=playing, 2=paused, 3=buffering, 5=cued - All overlays prevent context menu and clicks with preventDefault This approach is based on successful implementations found in: - Medium article "How We Safely Embed YouTube Videos" - xFanatical Safe Doc approach for educational content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,24 @@ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 're
|
|||||||
import Plyr from 'plyr';
|
import Plyr from 'plyr';
|
||||||
import 'plyr/dist/plyr.css';
|
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 {
|
interface VideoChapter {
|
||||||
time: number; // Time in seconds
|
time: number; // Time in seconds
|
||||||
title: string;
|
title: string;
|
||||||
@@ -35,8 +53,12 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const playerRef = useRef<Plyr | null>(null);
|
const playerRef = useRef<Plyr | null>(null);
|
||||||
const posterRef = useRef<HTMLDivElement | null>(null);
|
const posterRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const ytPlayerRef = useRef<any>(null);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [ytPlayerState, setYtPlayerState] = useState<number>(-1); // YouTube player state
|
||||||
|
const [ytApiReady, setYtApiReady] = useState<boolean>(false);
|
||||||
|
|
||||||
// Detect if this is a YouTube URL
|
// Detect if this is a YouTube URL
|
||||||
const isYouTube = videoUrl && (
|
const isYouTube = videoUrl && (
|
||||||
@@ -60,8 +82,27 @@ 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;
|
||||||
|
|
||||||
|
// Load YouTube IFrame API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wrapperRef.current) return;
|
if (window.YT && window.YT.Player) {
|
||||||
|
setYtApiReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
document.body.appendChild(tag);
|
||||||
|
|
||||||
|
window.onYouTubeIframeAPIReady = () => setYtApiReady(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.onYouTubeIframeAPIReady = undefined;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize Plyr with YouTube API integration
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wrapperRef.current || !isYouTube) return;
|
||||||
|
|
||||||
// Initialize Plyr
|
// Initialize Plyr
|
||||||
const player = new Plyr(wrapperRef.current, {
|
const player = new Plyr(wrapperRef.current, {
|
||||||
@@ -94,72 +135,67 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
|
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
|
|
||||||
// Create poster element manually for YouTube
|
// Wait for YouTube API to be ready and then initialize YT player
|
||||||
if (!player.elements.wrapper.querySelector('.plyr__poster')) {
|
const initializeYouTubePlayer = () => {
|
||||||
const poster = document.createElement('div');
|
if (!ytApiReady || !wrapperRef.current) return;
|
||||||
poster.className = 'plyr__poster';
|
|
||||||
poster.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: transparent;
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
pointer-events: auto;
|
|
||||||
`;
|
|
||||||
player.elements.wrapper.appendChild(poster);
|
|
||||||
posterRef.current = poster;
|
|
||||||
} else {
|
|
||||||
posterRef.current = player.elements.wrapper.querySelector('.plyr__poster') as HTMLDivElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track play/pause state to show/hide poster overlay with delay
|
const iframe = wrapperRef.current.querySelector('iframe');
|
||||||
let hidePosterTimeout: NodeJS.Timeout | null = null;
|
if (!iframe) return;
|
||||||
|
|
||||||
player.on('play', () => {
|
// Create YouTube Player instance
|
||||||
|
const ytPlayer = new window.YT.Player(iframe, {
|
||||||
|
events: {
|
||||||
|
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);
|
setIsPlaying(true);
|
||||||
// Clear any existing timeout
|
} else if (state === 2 || state === 0) {
|
||||||
if (hidePosterTimeout) {
|
setIsPlaying(false);
|
||||||
clearTimeout(hidePosterTimeout);
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ytPlayerRef.current = ytPlayer;
|
||||||
|
|
||||||
|
// Set up time tracking for chapters
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
if (ytPlayer && ytPlayer.getCurrentTime) {
|
||||||
|
const currentTime = ytPlayer.getCurrentTime();
|
||||||
|
if (onTimeUpdate) {
|
||||||
|
onTimeUpdate(currentTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait 500ms before fading out the poster (like your reference)
|
// Find current chapter
|
||||||
hidePosterTimeout = setTimeout(() => {
|
if (chapters.length > 0) {
|
||||||
if (posterRef.current) {
|
let index = chapters.findIndex((chapter, i) => {
|
||||||
posterRef.current.style.opacity = '0';
|
const nextChapter = chapters[i + 1];
|
||||||
posterRef.current.style.pointerEvents = 'none';
|
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);
|
}, 500);
|
||||||
});
|
};
|
||||||
|
|
||||||
player.on('pause', () => {
|
// Initialize YouTube player when API is ready
|
||||||
setIsPlaying(false);
|
if (ytApiReady) {
|
||||||
// Clear timeout if paused before poster hides
|
initializeYouTubePlayer();
|
||||||
if (hidePosterTimeout) {
|
|
||||||
clearTimeout(hidePosterTimeout);
|
|
||||||
hidePosterTimeout = null;
|
|
||||||
}
|
}
|
||||||
if (posterRef.current) {
|
|
||||||
posterRef.current.style.opacity = '1';
|
|
||||||
posterRef.current.style.pointerEvents = 'auto';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
player.on('ended', () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
// Clear timeout
|
|
||||||
if (hidePosterTimeout) {
|
|
||||||
clearTimeout(hidePosterTimeout);
|
|
||||||
hidePosterTimeout = null;
|
|
||||||
}
|
|
||||||
if (posterRef.current) {
|
|
||||||
posterRef.current.style.opacity = '1';
|
|
||||||
posterRef.current.style.pointerEvents = 'auto';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply custom accent color
|
// Apply custom accent color
|
||||||
if (accentColor) {
|
if (accentColor) {
|
||||||
@@ -186,59 +222,37 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
if (ytPlayerRef.current && ytPlayerRef.current.destroy) {
|
||||||
|
ytPlayerRef.current.destroy();
|
||||||
|
}
|
||||||
player.destroy();
|
player.destroy();
|
||||||
};
|
};
|
||||||
}, [accentColor]);
|
}, [isYouTube, ytApiReady, accentColor, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
||||||
|
|
||||||
// Handle chapter tracking
|
// Prevent context menu and unwanted interactions
|
||||||
useEffect(() => {
|
const preventAction = (e: React.MouseEvent) => {
|
||||||
if (!playerRef.current || chapters.length === 0) return;
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
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
|
// Jump to specific time
|
||||||
const jumpToTime = (time: number) => {
|
const jumpToTime = (time: number) => {
|
||||||
if (playerRef.current && isYouTube) {
|
if (ytPlayerRef.current) {
|
||||||
|
ytPlayerRef.current.seekTo(time, true);
|
||||||
|
ytPlayerRef.current.playVideo();
|
||||||
|
} else if (playerRef.current) {
|
||||||
playerRef.current.currentTime = time;
|
playerRef.current.currentTime = time;
|
||||||
playerRef.current.play();
|
playerRef.current.play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentTime = () => {
|
const getCurrentTime = () => {
|
||||||
|
if (ytPlayerRef.current) {
|
||||||
|
return ytPlayerRef.current.getCurrentTime() || 0;
|
||||||
|
}
|
||||||
return playerRef.current ? playerRef.current.currentTime : 0;
|
return playerRef.current ? playerRef.current.currentTime : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,12 +277,108 @@ export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWit
|
|||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`plyr__video-embed relative ${className}`}>
|
<div ref={wrapperRef} className={`plyr__video-embed relative ${className}`}>
|
||||||
{youtubeId && (
|
{youtubeId && (
|
||||||
|
<>
|
||||||
<iframe
|
<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`}
|
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&enablejsapi=1`}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* --- Strategic Overlays to Block YouTube UI --- */}
|
||||||
|
|
||||||
|
{/* 1. Block "Copy Link" and Title on top (always on) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '60px',
|
||||||
|
zIndex: 5,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onContextMenu={preventAction}
|
||||||
|
onClick={preventAction}
|
||||||
|
title="Educational content - external links disabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. Block "YouTube" logo - Bottom right (always on) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '10%',
|
||||||
|
height: '36px',
|
||||||
|
zIndex: 5,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onContextMenu={preventAction}
|
||||||
|
onClick={preventAction}
|
||||||
|
title="Educational content - external links disabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. Block "Watch on YouTube" - Bottom left (before video starts) */}
|
||||||
|
{(ytPlayerState === -1 || ytPlayerState === 5) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '25%',
|
||||||
|
height: '50px',
|
||||||
|
zIndex: 6,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onContextMenu={preventAction}
|
||||||
|
onClick={preventAction}
|
||||||
|
title="Educational content - external links disabled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. Block "More Videos" overlay - When paused */}
|
||||||
|
{ytPlayerState === 2 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '15%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '30%',
|
||||||
|
zIndex: 6,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onContextMenu={preventAction}
|
||||||
|
onClick={preventAction}
|
||||||
|
title="Educational content - external links disabled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5. Block related videos overlay - When video ends */}
|
||||||
|
{ytPlayerState === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '13%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '87%',
|
||||||
|
zIndex: 7,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onContextMenu={preventAction}
|
||||||
|
onClick={preventAction}
|
||||||
|
title="Educational content - external links disabled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user