import { useEffect, useRef, useState, forwardRef, useImperativeHandle, useCallback } from 'react'; import { Plyr } from 'plyr-react'; import 'plyr/dist/plyr.css'; import { useAdiloPlayer } from '@/hooks/useAdiloPlayer'; import { useVideoProgress } from '@/hooks/useVideoProgress'; import { Button } from '@/components/ui/button'; import { RotateCcw } from 'lucide-react'; interface VideoChapter { time: number; // Time in seconds title: string; } interface VideoPlayerWithChaptersProps { videoUrl?: string; embedCode?: string | null; m3u8Url?: string; mp4Url?: string; videoHost?: 'youtube' | 'adilo' | 'unknown'; chapters?: VideoChapter[]; accentColor?: string; onChapterChange?: (chapter: VideoChapter) => void; onTimeUpdate?: (time: number) => void; className?: string; videoId?: string; // For progress tracking videoType?: 'lesson' | 'webinar'; // For progress tracking } export interface VideoPlayerRef { jumpToTime: (time: number) => void; getCurrentTime: () => number; } export const VideoPlayerWithChapters = forwardRef(({ videoUrl, embedCode, m3u8Url, mp4Url, videoHost = 'unknown', chapters = [], accentColor, onChapterChange, onTimeUpdate, className = '', videoId, videoType, }, ref) => { const plyrRef = useRef(null); const currentChapterIndexRef = useRef(-1); const [currentChapterIndex, setCurrentChapterIndex] = useState(-1); const [currentTime, setCurrentTime] = useState(0); const [playerInstance, setPlayerInstance] = useState(null); const [showResumePrompt, setShowResumePrompt] = useState(false); const [resumeTime, setResumeTime] = useState(0); const saveProgressTimeoutRef = useRef(null); // Determine if using Adilo (M3U8) or YouTube const isAdilo = videoHost === 'adilo' || m3u8Url; const isYouTube = videoHost === 'youtube' || (videoUrl && (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be'))); // Video progress tracking const { progress, loading: progressLoading, saveProgress: saveProgressDirect, hasProgress } = useVideoProgress({ videoId: videoId || '', videoType: videoType || 'lesson', duration: playerInstance?.duration, }); // Debounced save function (saves every 5 seconds) const saveProgressDebounced = useCallback((time: number) => { if (saveProgressTimeoutRef.current) { clearTimeout(saveProgressTimeoutRef.current); } saveProgressTimeoutRef.current = setTimeout(() => { saveProgressDirect(time); }, 5000); }, [saveProgressDirect]); // Stable callback for finding current chapter const findCurrentChapter = useCallback((time: number) => { if (chapters.length === 0) return -1; let index = chapters.findIndex((chapter, i) => { const nextChapter = chapters[i + 1]; return time >= chapter.time && (!nextChapter || time < nextChapter.time); }); if (index === -1 && time < chapters[0].time) { return -1; } return index; }, [chapters]); // Stable onTimeUpdate callback for Adilo player const handleAdiloTimeUpdate = useCallback((time: number) => { setCurrentTime(time); onTimeUpdate?.(time); saveProgressDebounced(time); // Find and update current chapter for Adilo const index = findCurrentChapter(time); if (index !== currentChapterIndexRef.current) { currentChapterIndexRef.current = index; setCurrentChapterIndex(index); if (index >= 0 && onChapterChange) { onChapterChange(chapters[index]); } } }, [onTimeUpdate, onChapterChange, findCurrentChapter, chapters, saveProgressDebounced]); // Adilo player hook const adiloPlayer = useAdiloPlayer({ m3u8Url, mp4Url, onTimeUpdate: handleAdiloTimeUpdate, accentColor, }); // 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; // Block right-click and dev tools useEffect(() => { 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) return; // Wait for player to initialize const checkPlayer = setInterval(() => { const player = plyrRef.current?.plyr; if (player) { clearInterval(checkPlayer); setPlayerInstance(player); // Set up time tracking using Plyr's event API if (typeof player.on === 'function') { player.on('timeupdate', () => { const time = player.currentTime; setCurrentTime(time); if (onTimeUpdate) { onTimeUpdate(time); } saveProgressDebounced(time); // Find current chapter const index = findCurrentChapter(time); if (index !== currentChapterIndexRef.current) { currentChapterIndexRef.current = index; setCurrentChapterIndex(index); if (index >= 0 && onChapterChange) { onChapterChange(chapters[index]); } } }); } else { // Fallback: poll for time updates const interval = setInterval(() => { const time = player.currentTime; setCurrentTime(time); if (onTimeUpdate) { onTimeUpdate(time); } saveProgressDebounced(time); // Find current chapter const index = findCurrentChapter(time); if (index !== currentChapterIndexRef.current) { currentChapterIndexRef.current = index; setCurrentChapterIndex(index); if (index >= 0 && onChapterChange) { onChapterChange(chapters[index]); } } }, 500); // Store interval ID for cleanup return () => clearInterval(interval); } } }, 100); return () => clearInterval(checkPlayer); }, [isYouTube, findCurrentChapter, onChapterChange, onTimeUpdate, chapters, saveProgressDebounced]); // Jump to specific time using Plyr API or Adilo player const jumpToTime = (time: number) => { if (isAdilo) { const video = adiloPlayer.videoRef.current; if (video && adiloPlayer.isReady) { video.currentTime = time; const wasPlaying = !video.paused; if (wasPlaying) { video.play().catch((err) => { if (err.name !== 'AbortError') { console.error('Jump failed:', err); } }); } } } else if (playerInstance) { playerInstance.currentTime = time; playerInstance.play(); } }; const getCurrentTime = () => { return currentTime; }; // Check for saved progress and show resume prompt useEffect(() => { if (!progressLoading && hasProgress && progress && progress.last_position > 5) { setShowResumePrompt(true); setResumeTime(progress.last_position); } }, [progressLoading, hasProgress, progress]); const handleResume = () => { jumpToTime(resumeTime); setShowResumePrompt(false); }; const handleStartFromBeginning = () => { setShowResumePrompt(false); }; // Save progress immediately on pause/ended useEffect(() => { if (!adiloPlayer.videoRef.current) return; const video = adiloPlayer.videoRef.current; const handlePause = () => { // Save immediately on pause saveProgressDirect(video.currentTime); }; const handleEnded = () => { // Save immediately on end saveProgressDirect(video.currentTime); }; video.addEventListener('pause', handlePause); video.addEventListener('ended', handleEnded); return () => { video.removeEventListener('pause', handlePause); video.removeEventListener('ended', handleEnded); }; }, [adiloPlayer.videoRef, saveProgressDirect]); // Keyboard shortcuts useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { // Ignore if user is typing in an input if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || e.target.isContentEditable ) { return; } const player = isAdilo ? adiloPlayer.videoRef.current : playerInstance; if (!player) return; // Space: Play/Pause if (e.code === 'Space') { e.preventDefault(); if (isAdilo) { const video = player as HTMLVideoElement; video.paused ? video.play() : video.pause(); } else { player.playing ? player.pause() : player.play(); } } // Arrow Left: Back 5 seconds if (e.code === 'ArrowLeft') { e.preventDefault(); const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime; jumpToTime(Math.max(0, currentTime - 5)); } // Arrow Right: Forward 5 seconds if (e.code === 'ArrowRight') { e.preventDefault(); const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime; const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration; jumpToTime(Math.min(duration, currentTime + 5)); } // Arrow Up: Volume up 10% if (e.code === 'ArrowUp') { e.preventDefault(); if (!isAdilo) { const newVolume = Math.min(1, player.volume + 0.1); player.volume = newVolume; } } // Arrow Down: Volume down 10% if (e.code === 'ArrowDown') { e.preventDefault(); if (!isAdilo) { const newVolume = Math.max(0, player.volume - 0.1); player.volume = newVolume; } } // F: Fullscreen if (e.code === 'KeyF') { e.preventDefault(); if (isAdilo) { if (document.fullscreenElement) { document.exitFullscreen(); } else { (player as HTMLVideoElement).parentElement?.requestFullscreen(); } } else { player.fullscreen.toggle(); } } // M: Mute if (e.code === 'KeyM') { e.preventDefault(); if (isAdilo) { (player as HTMLVideoElement).muted = !(player as HTMLVideoElement).muted; } else { player.muted = !player.muted; } } // J: Back 10 seconds if (e.code === 'KeyJ') { e.preventDefault(); const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime; jumpToTime(Math.max(0, currentTime - 10)); } // L: Forward 10 seconds if (e.code === 'KeyL') { e.preventDefault(); const currentTime = isAdilo ? (player as HTMLVideoElement).currentTime : player.currentTime; const duration = isAdilo ? (player as HTMLVideoElement).duration : player.duration; jumpToTime(Math.min(duration, currentTime + 10)); } }; document.addEventListener('keydown', handleKeyPress); return () => document.removeEventListener('keydown', handleKeyPress); }, [isAdilo, adiloPlayer.isReady, playerInstance]); // Expose methods via ref useImperativeHandle(ref, () => ({ jumpToTime, getCurrentTime, })); // Adilo M3U8 Player with Video.js if (isAdilo) { return (
{/* Resume prompt */} {showResumePrompt && (
Lanjutkan dari posisi terakhir?
{Math.floor(resumeTime / 60)}:{String(Math.floor(resumeTime % 60)).padStart(2, '0')}
)}
); } if (useEmbed) { // Custom embed (Vimeo, etc. - not Adilo anymore) return (
); } const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null; // Apply custom accent color useEffect(() => { 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 && ( <>
)}
); });