diff --git a/package-lock.json b/package-lock.json index df8f6cd..3ce18a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "plyr": "^3.8.3", "plyr-react": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", @@ -4261,7 +4262,6 @@ "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -4308,8 +4308,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", @@ -5397,8 +5396,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", @@ -6249,7 +6247,6 @@ "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz", "integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==", "license": "MIT", - "peer": true, "dependencies": { "core-js": "^3.45.1", "custom-event-polyfill": "^1.0.7", @@ -6691,8 +6688,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react": { "version": "18.3.1", @@ -7538,8 +7534,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", diff --git a/package.json b/package.json index c846946..0e6a88c 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "plyr": "^3.8.3", "plyr-react": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", diff --git a/src/components/VideoPlayerWithChapters.tsx b/src/components/VideoPlayerWithChapters.tsx index b4eb651..63b7880 100644 --- a/src/components/VideoPlayerWithChapters.tsx +++ b/src/components/VideoPlayerWithChapters.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; +import { Plyr } from 'plyr-react'; +import 'plyr/dist/plyr.css'; interface VideoChapter { time: number; // Time in seconds @@ -29,10 +31,10 @@ export const VideoPlayerWithChapters = forwardRef { - const iframeRef = useRef(null); + const plyrRef = useRef(null); const [currentChapterIndex, setCurrentChapterIndex] = useState(-1); const [currentTime, setCurrentTime] = useState(0); - const [isApiReady, setIsApiReady] = useState(false); + const [playerInstance, setPlayerInstance] = useState(null); // Detect if this is a YouTube URL const isYouTube = videoUrl && ( @@ -56,134 +58,103 @@ export const VideoPlayerWithChapters = forwardRef { - if (!isYouTube) return; - - // Check if API is already loaded - if ((window as any).YT && (window as any).YT.Player) { - setIsApiReady(true); - return; - } - - // Load YouTube IFrame API - const tag = document.createElement('script'); - tag.src = 'https://www.youtube.com/iframe_api'; - const firstScriptTag = document.getElementsByTagName('script')[0]; - if (firstScriptTag && firstScriptTag.parentNode) { - firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); - } else { - document.body.appendChild(tag); - } - - // Set up callback - (window as any).onYouTubeIframeAPIReady = () => { - setIsApiReady(true); - }; - - return () => { - (window as any).onYouTubeIframeAPIReady = null; - }; - }, [isYouTube]); - - // Initialize YouTube player and set up tracking - useEffect(() => { - if (!isYouTube || !isApiReady || !iframeRef.current) return; - - // Wait for iframe to be ready - const initializePlayer = () => { - if (!iframeRef.current || !(window as any).YT) return; - - const youtubeId = getYouTubeId(effectiveVideoUrl); - 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 { - const time = player.getCurrentTime(); - setCurrentTime(time); - - if (onTimeUpdate) { - onTimeUpdate(time); - } - - // Find current chapter - if (chapters.length > 0) { - 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) { - // Player not ready yet, ignore - } - }, 500); - - return () => { - clearInterval(interval); - if (player && player.destroy) { - player.destroy(); - } - }; - }; - - const timeout = setTimeout(initializePlayer, 100); - - return () => { - clearTimeout(timeout); - }; - }, [isYouTube, isApiReady, effectiveVideoUrl, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]); - - // Jump to specific time using YouTube API - const jumpToTime = (time: number) => { - const iframe = iframeRef.current; - if (!iframe || !iframe.contentWindow) return; - - // Try YouTube API first - if (isApiReady && (window as any).YT && (window as any).YT.getPlayerById) { - const player = (window as any).YT.getPlayerById(iframe.id); - if (player) { - player.seekTo(time, true); - player.playVideo(); - return; + const blockRightClick = (e: MouseEvent | KeyboardEvent) => { + // Block right-click + if (e.type === 'contextmenu') { + e.preventDefault(); + return false; } + + // Block F12, Ctrl+Shift+I, Ctrl+Shift+J, Ctrl+U + const keyboardEvent = e as KeyboardEvent; + if ( + keyboardEvent.key === 'F12' || + (keyboardEvent.ctrlKey && keyboardEvent.shiftKey && (keyboardEvent.key === 'I' || keyboardEvent.key === 'J')) || + (keyboardEvent.ctrlKey && keyboardEvent.key === 'U') + ) { + e.preventDefault(); + return false; + } + }; + + document.addEventListener('contextmenu', blockRightClick); + document.addEventListener('keydown', blockRightClick); + + return () => { + document.removeEventListener('contextmenu', blockRightClick); + document.removeEventListener('keydown', blockRightClick); + }; + }, []); + + // Initialize Plyr and set up time tracking + useEffect(() => { + if (!isYouTube || !plyrRef.current) return; + + const plyrElement = plyrRef.current; + const player = plyrElement?.plyr; + + if (!player) { + // Wait for player to be ready + const timeout = setTimeout(() => { + const p = plyrElement?.plyr; + if (p) { + setPlayerInstance(p); + setupTimeTracking(p); + } + }, 100); + return () => clearTimeout(timeout); } - // 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":[]}`, - 'https://www.youtube.com' - ); - }, 100); + setPlayerInstance(player); + setupTimeTracking(player); + + function setupTimeTracking(plyr: any) { + // Track time updates for chapter highlighting + plyr.on('timeupdate', (event: any) => { + const time = plyr.currentTime; + setCurrentTime(time); + + if (onTimeUpdate) { + onTimeUpdate(time); + } + + // Find current chapter + if (chapters.length > 0) { + 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]); + } + } + } + }); + } + + return () => { + if (player) { + player.off('timeupdate'); + } + }; + }, [isYouTube, chapters, currentChapterIndex, onChapterChange, onTimeUpdate]); + + // Jump to specific time using Plyr API + const jumpToTime = (time: number) => { + if (playerInstance) { + playerInstance.currentTime = time; + playerInstance.play(); + } }; const getCurrentTime = () => { @@ -208,53 +179,97 @@ export const VideoPlayerWithChapters = forwardRef { + if (!accentColor || !plyrRef.current) return; + + const style = document.createElement('style'); + style.textContent = ` + .plyr__control--overlared, + .plyr__controls .plyr__control.plyr__tab-focus, + .plyr__controls .plyr__control:hover, + .plyr__controls .plyr__control[aria-current='true'] { + background: ${accentColor} !important; + } + .plyr__progress__value { + background: ${accentColor} !important; + } + .plyr__volume__value { + background: ${accentColor} !important; + } + `; + document.head.appendChild(style); + + return () => { + document.head.removeChild(style); + }; + }, [accentColor]); + return ( -
+
{youtubeId && ( <> -