From b7bde1df04cb32be74047c85feaa5bd7a7fdcb87 Mon Sep 17 00:00:00 2001 From: dwindown Date: Sun, 4 Jan 2026 11:25:01 +0700 Subject: [PATCH] Fix video player reloading by moving VideoPlayer component outside main component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move VideoPlayer component outside Bootcamp component to prevent re-creation on every render - This prevents useAdiloPlayer and all hooks from re-initializing unnecessarily - Video now plays and jumps correctly without reloading - Component structure now matches WebinarRecording page pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/pages/Bootcamp.tsx | 450 ++++++++++++++++++++++++++++------------- 1 file changed, 307 insertions(+), 143 deletions(-) diff --git a/src/pages/Bootcamp.tsx b/src/pages/Bootcamp.tsx index 432b579..e5c331a 100644 --- a/src/pages/Bootcamp.tsx +++ b/src/pages/Bootcamp.tsx @@ -205,10 +205,28 @@ export default function Bootcamp() { setLoading(false); }; +} - const isLessonCompleted = (lessonId: string) => { - return progress.some(p => p.lesson_id === lessonId); - }; +// Helper function to get YouTube embed URL +const getYouTubeEmbedUrl = (url: string): string => { + const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/); + return match ? `https://www.youtube.com/embed/${match[1]}` : url; +}; + +// Move VideoPlayer component outside main component to prevent re-creation on every render +const VideoPlayer = ({ + lesson, + playerRef, + currentTime, + accentColor, + setCurrentTime +}: { + lesson: Lesson; + playerRef: React.RefObject; + currentTime: number; + accentColor: string; + setCurrentTime: (time: number) => void; +}) => { const formatTime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); @@ -221,6 +239,289 @@ export default function Bootcamp() { return `${minutes}:${secs.toString().padStart(2, '0')}`; }; + const hasChapters = lesson.chapters && lesson.chapters.length > 0; + + // Get video based on lesson's video_host (prioritize Adilo) + const getVideoSource = () => { + // If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority. + const lessonVideoHost = lesson.video_host || ( + lesson.m3u8_url ? 'adilo' : + lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' : + lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' : + 'unknown' + ); + + if (lessonVideoHost === 'adilo') { + // Adilo M3U8 streaming + if (lesson.m3u8_url && lesson.m3u8_url.trim()) { + return { + type: 'adilo', + m3u8Url: lesson.m3u8_url, + mp4Url: lesson.mp4_url || undefined, + videoHost: 'adilo' + }; + } else if (lesson.mp4_url && lesson.mp4_url.trim()) { + // Fallback to MP4 only + return { + type: 'adilo', + mp4Url: lesson.mp4_url, + videoHost: 'adilo' + }; + } + } + + // YouTube or fallback + if (lessonVideoHost === 'youtube') { + if (lesson.youtube_url && lesson.youtube_url.trim()) { + return { + type: 'youtube', + url: lesson.youtube_url, + embedUrl: getYouTubeEmbedUrl(lesson.youtube_url) + }; + } else if (lesson.video_url && lesson.video_url.trim()) { + // Fallback to old video_url for backward compatibility + return { + type: 'youtube', + url: lesson.video_url, + embedUrl: getYouTubeEmbedUrl(lesson.video_url) + }; + } + } + + // Final fallback: try embed code + return lesson.embed_code && lesson.embed_code.trim() ? { + type: 'embed', + html: lesson.embed_code + } : null; + }; + + // Memoize video source to prevent unnecessary re-renders + const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]); + + // Show warning if no video available + if (!video) { + return ( + + +

Konten tidak tersedia

+

Video belum dikonfigurasi untuk pelajaran ini.

+
+
+ ); + } + + // Render based on video type + if (video.type === 'embed') { + return ( +
+
+
+
+ {hasChapters && ( +
+ +
+ )} +
+ ); + } + + // Adilo or YouTube with chapters support + const isYouTube = video.type === 'youtube'; + const isAdilo = video.type === 'adilo'; + + // Memoize URL values to ensure they're stable across renders + const videoUrl = useMemo(() => (isYouTube ? video.url : undefined), [isYouTube, video.url]); + const m3u8Url = useMemo(() => (isAdilo ? video.m3u8Url : undefined), [isAdilo, video.m3u8Url]); + const mp4Url = useMemo(() => (isAdilo ? video.mp4Url : undefined), [isAdilo, video.mp4Url]); + + return ( + <> + {/* Video Player - Full Width */} +
+ +
+ + {/* Timeline Chapters - Below video like WebinarRecording */} + {hasChapters && ( +
+ { + if (playerRef.current) { + playerRef.current.jumpToTime(time); + } + }} + currentTime={currentTime} + accentColor={accentColor} + /> +
+ )} + + ); +}; + +export default function Bootcamp() { + const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>(); + const navigate = useNavigate(); + const { user, loading: authLoading } = useAuth(); + + const [product, setProduct] = useState(null); + const [modules, setModules] = useState([]); + const [progress, setProgress] = useState([]); + const [selectedLesson, setSelectedLesson] = useState(null); + const [loading, setLoading] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [userReview, setUserReview] = useState(null); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [accentColor, setAccentColor] = useState(''); + const playerRef = useRef(null); + + useEffect(() => { + if (!authLoading && !user) { + navigate('/auth'); + } else if (user && slug) { + checkAccessAndFetch(); + } + }, [user, authLoading, slug]); + + const checkAccessAndFetch = async () => { + const { data: productData, error: productError } = await supabase + .from('products') + .select('id, title, slug') + .eq('slug', slug) + .eq('type', 'bootcamp') + .maybeSingle(); + + if (productError || !productData) { + toast({ title: 'Error', description: 'Bootcamp tidak ditemukan', variant: 'destructive' }); + navigate('/dashboard'); + return; + } + + setProduct(productData); + + // Fetch accent color from settings + const { data: settings } = await supabase + .from('platform_settings') + .select('brand_accent_color') + .single(); + + if (settings?.brand_accent_color) { + setAccentColor(settings.brand_accent_color); + } + + const { data: accessData } = await supabase + .from('user_access') + .select('id') + .eq('user_id', user!.id) + .eq('product_id', productData.id) + .maybeSingle(); + + if (!accessData) { + toast({ title: 'Akses ditolak', description: 'Anda tidak memiliki akses ke bootcamp ini', variant: 'destructive' }); + navigate('/dashboard'); + return; + } + + const { data: modulesData } = await supabase + .from('bootcamp_modules') + .select(` + id, + title, + position, + bootcamp_lessons ( + id, + title, + content, + video_url, + youtube_url, + embed_code, + m3u8_url, + mp4_url, + video_host, + duration_seconds, + position, + release_at, + chapters + ) + `) + .eq('product_id', productData.id) + .order('position'); + + if (modulesData) { + const sortedModules = modulesData.map(m => ({ + ...m, + lessons: (m.bootcamp_lessons as Lesson[]).sort((a, b) => a.position - b.position) + })); + setModules(sortedModules); + + // Select lesson based on URL parameter or default to first lesson + const allLessons = sortedModules.flatMap(m => m.lessons); + + if (lessonId) { + // Find the lesson by ID from URL + const lessonFromUrl = allLessons.find(l => l.id === lessonId); + if (lessonFromUrl) { + setSelectedLesson(lessonFromUrl); + } else if (allLessons.length > 0) { + // If lesson not found, default to first lesson + setSelectedLesson(allLessons[0]); + } + } else if (allLessons.length > 0 && sortedModules[0].lessons.length > 0) { + // No lessonId in URL, select first lesson + setSelectedLesson(sortedModules[0].lessons[0]); + } + } + + const { data: progressData } = await supabase + .from('lesson_progress') + .select('lesson_id, completed_at') + .eq('user_id', user!.id); + + if (progressData) { + setProgress(progressData); + } + + // Check if user has already reviewed this bootcamp + const { data: reviewData } = await supabase + .from('reviews') + .select('id, rating, title, body, is_approved, created_at') + .eq('user_id', user!.id) + .eq('product_id', productData.id) + .order('created_at', { ascending: false }) + .limit(1); + + if (reviewData && reviewData.length > 0) { + setUserReview(reviewData[0] as UserReview); + } else { + setUserReview(null); + } + + setLoading(false); + }; + + const isLessonCompleted = (lessonId: string) => { + return progress.some(p => p.lesson_id === lessonId); + }; + const handleSelectLesson = (lesson: Lesson) => { setSelectedLesson(lesson); // Update URL without full page reload @@ -245,11 +546,12 @@ export default function Bootcamp() { const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]; setProgress(newProgress); - + // Calculate completion percentage for notification const completedCount = newProgress.length; + const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0); const completionPercent = Math.round((completedCount / totalLessons) * 100); - + // Trigger progress notification at milestones if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) { try { @@ -293,144 +595,6 @@ export default function Bootcamp() { } }; - const getYouTubeEmbedUrl = (url: string): string => { - const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/); - return match ? `https://www.youtube.com/embed/${match[1]}` : url; - }; - - const VideoPlayer = ({ lesson }: { lesson: Lesson }) => { - const hasChapters = lesson.chapters && lesson.chapters.length > 0; - - // Get video based on lesson's video_host (prioritize Adilo) - const getVideoSource = () => { - // If video_host is explicitly set, use it. Otherwise auto-detect with Adilo priority. - const lessonVideoHost = lesson.video_host || ( - lesson.m3u8_url ? 'adilo' : - lesson.video_url?.includes('adilo.bigcommand.com') ? 'adilo' : - lesson.youtube_url || lesson.video_url?.includes('youtube.com') || lesson.video_url?.includes('youtu.be') ? 'youtube' : - 'unknown' - ); - - if (lessonVideoHost === 'adilo') { - // Adilo M3U8 streaming - if (lesson.m3u8_url && lesson.m3u8_url.trim()) { - return { - type: 'adilo', - m3u8Url: lesson.m3u8_url, - mp4Url: lesson.mp4_url || undefined, - videoHost: 'adilo' - }; - } else if (lesson.mp4_url && lesson.mp4_url.trim()) { - // Fallback to MP4 only - return { - type: 'adilo', - mp4Url: lesson.mp4_url, - videoHost: 'adilo' - }; - } - } - - // YouTube or fallback - if (lessonVideoHost === 'youtube') { - if (lesson.youtube_url && lesson.youtube_url.trim()) { - return { - type: 'youtube', - url: lesson.youtube_url, - embedUrl: getYouTubeEmbedUrl(lesson.youtube_url) - }; - } else if (lesson.video_url && lesson.video_url.trim()) { - // Fallback to old video_url for backward compatibility - return { - type: 'youtube', - url: lesson.video_url, - embedUrl: getYouTubeEmbedUrl(lesson.video_url) - }; - } - } - - // Final fallback: try embed code - return lesson.embed_code && lesson.embed_code.trim() ? { - type: 'embed', - html: lesson.embed_code - } : null; - }; - - // Memoize video source to prevent unnecessary re-renders - const video = useMemo(getVideoSource, [lesson.id, lesson.video_host, lesson.m3u8_url, lesson.mp4_url, lesson.youtube_url, lesson.video_url, lesson.embed_code]); - - // Show warning if no video available - if (!video) { - return ( - - -

Konten tidak tersedia

-

Video belum dikonfigurasi untuk pelajaran ini.

-
-
- ); - } - - // Render based on video type - if (video.type === 'embed') { - return ( -
-
-
-
- {hasChapters && ( -
- -
- )} -
- ); - } - - // Adilo or YouTube with chapters support - const isYouTube = video.type === 'youtube'; - const isAdilo = video.type === 'adilo'; - - return ( - <> - {/* Video Player - Full Width */} -
- -
- - {/* Timeline Chapters - Below video like WebinarRecording */} - {hasChapters && ( -
- { - if (playerRef.current) { - playerRef.current.jumpToTime(time); - } - }} - currentTime={currentTime} - accentColor={accentColor} - /> -
- )} - - ); - }; - const completedCount = progress.length; const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0); const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;