Files
meet-hub/src/components/VideoPlayerWithChapters.tsx
dwindown 60baf32f73 Display bootcamp lesson chapters on Product Detail page as marketing content
This commit implements displaying lesson chapters/timeline as marketing content
on the Product Detail page for bootcamp products, helping potential buyers
understand the detailed breakdown of what they'll learn.

## Changes

### Product Detail Page (src/pages/ProductDetail.tsx)
- Updated Lesson interface to include optional chapters property
- Modified fetchCurriculum to fetch chapters along with lessons
- Enhanced renderCurriculumPreview to display chapters as nested content under lessons
- Chapters shown with timestamps and titles, clickable to navigate to bootcamp access page
- Visual hierarchy: Module → Lesson → Chapters with proper indentation and styling

### Review System Fixes
- Fixed review prompt re-appearing after submission (before admin approval)
- Added hasSubmittedReview check to prevent showing prompt when review exists
- Fixed edit review functionality to pre-populate form with existing data
- ReviewModal now handles both INSERT (new) and UPDATE (edit) operations
- Edit resets is_approved to false requiring re-approval

### Video Player Enhancements
- Implemented Adilo/Video.js integration for M3U8/HLS playback
- Added video progress tracking with refs pattern for reliability
- Implemented chapter navigation for both Adilo and YouTube players
- Added keyboard shortcuts (Space, Arrows, F, M, J, L)
- Resume prompt for returning users with saved progress

### Database Migrations
- Added Adilo video support fields (m3u8_url, mp4_url, video_host)
- Created video_progress table for tracking user watch progress
- Fixed consulting slots user_id foreign key
- Added chapters support to products and bootcamp_lessons tables

### Documentation
- Added Adilo implementation plan and quick reference docs
- Cleaned up transcript analysis files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-01 23:54:32 +07:00

571 lines
17 KiB
TypeScript

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<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
videoUrl,
embedCode,
m3u8Url,
mp4Url,
videoHost = 'unknown',
chapters = [],
accentColor,
onChapterChange,
onTimeUpdate,
className = '',
videoId,
videoType,
}, ref) => {
const plyrRef = useRef<any>(null);
const currentChapterIndexRef = useRef<number>(-1);
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
const [currentTime, setCurrentTime] = useState(0);
const [playerInstance, setPlayerInstance] = useState<any>(null);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [resumeTime, setResumeTime] = useState(0);
const saveProgressTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div className={`relative ${className}`}>
<div className="aspect-video rounded-lg overflow-hidden bg-black vjs-big-play-centered">
<video
ref={adiloPlayer.videoRef}
className="video-js vjs-default-skin vjs-big-play-centered vjs-fill"
playsInline
/>
</div>
{/* Resume prompt */}
{showResumePrompt && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-lg">
<div className="text-center space-y-4 p-6">
<div className="text-white text-lg font-semibold">
Lanjutkan dari posisi terakhir?
</div>
<div className="text-gray-300 text-sm">
{Math.floor(resumeTime / 60)}:{String(Math.floor(resumeTime % 60)).padStart(2, '0')}
</div>
<div className="flex gap-3 justify-center">
<Button
onClick={handleResume}
className="bg-primary hover:bg-primary/90"
>
<RotateCcw className="w-4 h-4 mr-2" />
Lanjutkan
</Button>
<Button
onClick={handleStartFromBeginning}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
Mulai dari awal
</Button>
</div>
</div>
</div>
)}
</div>
);
}
if (useEmbed) {
// Custom embed (Vimeo, etc. - not Adilo anymore)
return (
<div
className={`aspect-video rounded-lg overflow-hidden ${className}`}
dangerouslySetInnerHTML={{ __html: embedCode }}
/>
);
}
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 (
<div className={`relative ${className}`}>
{youtubeId && (
<>
<div style={{ position: 'relative', pointerEvents: 'auto' }}>
<Plyr
ref={plyrRef}
source={{
type: 'video',
sources: [
{
src: `https://www.youtube.com/watch?v=${youtubeId}`,
provider: 'youtube',
},
],
}}
options={{
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen',
],
speed: {
selected: 1,
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
},
youtube: {
noCookie: true,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
modestbranding: 1,
controls: 0,
disablekb: 1,
fs: 0,
},
hideControls: false,
keyboardShortcuts: {
focused: true,
global: true,
},
}}
/>
</div>
<style>{`
/* Block YouTube UI overlays */
.plyr__video-wrapper .plyr__video-embed iframe {
pointer-events: none !important;
}
/* Only allow clicks on Plyr controls */
.plyr__controls,
.plyr__control--overlaid {
pointer-events: auto !important;
}
/* Hide YouTube's native play button that appears behind */
.plyr__video-wrapper::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
`}</style>
</>
)}
</div>
);
});