Fix video player reloading by moving VideoPlayer component outside main component

- 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 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-01-04 11:25:01 +07:00
parent 2b98a5460d
commit b7bde1df04

View File

@@ -205,11 +205,29 @@ 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<VideoPlayerRef>;
currentTime: number;
accentColor: string;
setCurrentTime: (time: number) => void;
}) => {
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -221,84 +239,6 @@ export default function Bootcamp() {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const handleSelectLesson = (lesson: Lesson) => {
setSelectedLesson(lesson);
// Update URL without full page reload
navigate(`/bootcamp/${slug}/${lesson.id}`);
};
const markAsCompleted = async () => {
if (!selectedLesson || !user || !product) return;
const { error } = await supabase
.from('lesson_progress')
.insert({ user_id: user.id, lesson_id: selectedLesson.id });
if (error) {
if (error.code === '23505') {
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
} else {
toast({ title: 'Error', description: 'Gagal menandai selesai', variant: 'destructive' });
}
return;
}
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
setProgress(newProgress);
// Calculate completion percentage for notification
const completedCount = newProgress.length;
const completionPercent = Math.round((completedCount / totalLessons) * 100);
// Trigger progress notification at milestones
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
try {
await supabase.functions.invoke('send-notification', {
body: {
template_key: 'bootcamp_progress',
recipient_email: user.email,
recipient_name: user.user_metadata?.name || 'Peserta',
variables: {
bootcamp_title: product.title,
progress_percent: completionPercent.toString(),
completed_lessons: completedCount.toString(),
total_lessons: totalLessons.toString(),
},
},
});
} catch (err) {
console.log('Progress notification skipped:', err);
}
}
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
const goToNextLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex < allLessons.length - 1) {
setSelectedLesson(allLessons[currentIndex + 1]);
}
};
const goToPrevLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex > 0) {
setSelectedLesson(allLessons[currentIndex - 1]);
}
};
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)
@@ -394,15 +334,20 @@ export default function Bootcamp() {
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 */}
<div className="mb-6">
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={isYouTube ? video.url : undefined}
m3u8Url={isAdilo ? video.m3u8Url : undefined}
mp4Url={isAdilo ? video.mp4Url : undefined}
videoUrl={videoUrl}
m3u8Url={m3u8Url}
mp4Url={mp4Url}
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
chapters={lesson.chapters}
accentColor={accentColor}
@@ -431,6 +376,225 @@ export default function Bootcamp() {
);
};
export default function Bootcamp() {
const { slug, lessonId } = useParams<{ slug: string; lessonId?: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [product, setProduct] = useState<Product | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [progress, setProgress] = useState<Progress[]>([]);
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [userReview, setUserReview] = useState<UserReview | null>(null);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [accentColor, setAccentColor] = useState<string>('');
const playerRef = useRef<VideoPlayerRef>(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
navigate(`/bootcamp/${slug}/${lesson.id}`);
};
const markAsCompleted = async () => {
if (!selectedLesson || !user || !product) return;
const { error } = await supabase
.from('lesson_progress')
.insert({ user_id: user.id, lesson_id: selectedLesson.id });
if (error) {
if (error.code === '23505') {
toast({ title: 'Info', description: 'Pelajaran sudah ditandai selesai' });
} else {
toast({ title: 'Error', description: 'Gagal menandai selesai', variant: 'destructive' });
}
return;
}
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 {
await supabase.functions.invoke('send-notification', {
body: {
template_key: 'bootcamp_progress',
recipient_email: user.email,
recipient_name: user.user_metadata?.name || 'Peserta',
variables: {
bootcamp_title: product.title,
progress_percent: completionPercent.toString(),
completed_lessons: completedCount.toString(),
total_lessons: totalLessons.toString(),
},
},
});
} catch (err) {
console.log('Progress notification skipped:', err);
}
}
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson();
};
const goToNextLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex < allLessons.length - 1) {
setSelectedLesson(allLessons[currentIndex + 1]);
}
};
const goToPrevLesson = () => {
if (!selectedLesson) return;
const allLessons = modules.flatMap(m => m.lessons);
const currentIndex = allLessons.findIndex(l => l.id === selectedLesson.id);
if (currentIndex > 0) {
setSelectedLesson(allLessons[currentIndex - 1]);
}
};
const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;