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,10 +205,28 @@ export default function Bootcamp() {
setLoading(false); setLoading(false);
}; };
}
const isLessonCompleted = (lessonId: string) => { // Helper function to get YouTube embed URL
return progress.some(p => p.lesson_id === lessonId); 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 formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
@@ -221,6 +239,289 @@ export default function Bootcamp() {
return `${minutes}:${secs.toString().padStart(2, '0')}`; 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 (
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
<CardContent className="py-12 text-center">
<p className="text-destructive font-medium">Konten tidak tersedia</p>
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
</CardContent>
</Card>
);
}
// Render based on video type
if (video.type === 'embed') {
return (
<div className="mb-6">
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
<div dangerouslySetInnerHTML={{ __html: video.html }} />
</div>
{hasChapters && (
<div className="mt-4">
<TimelineChapters
chapters={lesson.chapters}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</div>
);
}
// 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 */}
<div className="mb-6">
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={videoUrl}
m3u8Url={m3u8Url}
mp4Url={mp4Url}
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
chapters={lesson.chapters}
accentColor={accentColor}
onTimeUpdate={setCurrentTime}
videoId={lesson.id}
videoType="lesson"
/>
</div>
{/* Timeline Chapters - Below video like WebinarRecording */}
{hasChapters && (
<div className="mb-6">
<TimelineChapters
chapters={lesson.chapters}
onChapterClick={(time) => {
if (playerRef.current) {
playerRef.current.jumpToTime(time);
}
}}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</>
);
};
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) => { const handleSelectLesson = (lesson: Lesson) => {
setSelectedLesson(lesson); setSelectedLesson(lesson);
// Update URL without full page reload // Update URL without full page reload
@@ -248,6 +549,7 @@ export default function Bootcamp() {
// Calculate completion percentage for notification // Calculate completion percentage for notification
const completedCount = newProgress.length; const completedCount = newProgress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const completionPercent = Math.round((completedCount / totalLessons) * 100); const completionPercent = Math.round((completedCount / totalLessons) * 100);
// Trigger progress notification at milestones // Trigger progress notification at milestones
@@ -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 (
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
<CardContent className="py-12 text-center">
<p className="text-destructive font-medium">Konten tidak tersedia</p>
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
</CardContent>
</Card>
);
}
// Render based on video type
if (video.type === 'embed') {
return (
<div className="mb-6">
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
<div dangerouslySetInnerHTML={{ __html: video.html }} />
</div>
{hasChapters && (
<div className="mt-4">
<TimelineChapters
chapters={lesson.chapters}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</div>
);
}
// Adilo or YouTube with chapters support
const isYouTube = video.type === 'youtube';
const isAdilo = video.type === 'adilo';
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}
videoHost={isAdilo ? 'adilo' : isYouTube ? 'youtube' : 'unknown'}
chapters={lesson.chapters}
accentColor={accentColor}
onTimeUpdate={setCurrentTime}
videoId={lesson.id}
videoType="lesson"
/>
</div>
{/* Timeline Chapters - Below video like WebinarRecording */}
{hasChapters && (
<div className="mb-6">
<TimelineChapters
chapters={lesson.chapters}
onChapterClick={(time) => {
if (playerRef.current) {
playerRef.current.jumpToTime(time);
}
}}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</>
);
};
const completedCount = progress.length; const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0); const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons; const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;