From cbb6637966c6ae160f51c43346f6dd91dd07311e Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 12:07:12 -0600 Subject: [PATCH] [OQI-002] feat: Add education progress and assessment components - CourseProgressTracker: Comprehensive progress visualization - LearningPathVisualizer: Visual roadmap for learning paths - VideoProgressPlayer: Enhanced video player with bookmarks and controls - AssessmentSummaryCard: Quiz results analysis and review Co-Authored-By: Claude Opus 4.5 --- .../components/AssessmentSummaryCard.tsx | 465 +++++++++++++++ .../components/CourseProgressTracker.tsx | 310 ++++++++++ .../components/LearningPathVisualizer.tsx | 418 +++++++++++++ .../components/VideoProgressPlayer.tsx | 554 ++++++++++++++++++ src/modules/education/components/index.ts | 14 + 5 files changed, 1761 insertions(+) create mode 100644 src/modules/education/components/AssessmentSummaryCard.tsx create mode 100644 src/modules/education/components/CourseProgressTracker.tsx create mode 100644 src/modules/education/components/LearningPathVisualizer.tsx create mode 100644 src/modules/education/components/VideoProgressPlayer.tsx diff --git a/src/modules/education/components/AssessmentSummaryCard.tsx b/src/modules/education/components/AssessmentSummaryCard.tsx new file mode 100644 index 0000000..3958e52 --- /dev/null +++ b/src/modules/education/components/AssessmentSummaryCard.tsx @@ -0,0 +1,465 @@ +/** + * AssessmentSummaryCard Component + * Comprehensive quiz/assessment results and analysis + * OQI-002: Modulo Educativo + */ + +import React, { useState, useMemo } from 'react'; +import { + Trophy, + Award, + Target, + Clock, + CheckCircle, + XCircle, + TrendingUp, + TrendingDown, + BarChart3, + Download, + RefreshCw, + ChevronDown, + ChevronUp, + Lightbulb, + BookOpen, + AlertCircle, + Star, + Zap, +} from 'lucide-react'; + +export interface QuestionResult { + id: string; + questionText: string; + category: string; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; + explanation?: string; + timeSpentSeconds: number; +} + +export interface AssessmentResult { + assessmentId: string; + title: string; + score: number; + passingScore: number; + passed: boolean; + totalQuestions: number; + correctAnswers: number; + incorrectAnswers: number; + skippedAnswers: number; + timeSpentMinutes: number; + timeLimitMinutes?: number; + attemptNumber: number; + maxAttempts?: number; + completedAt: string; + xpEarned: number; + questions: QuestionResult[]; + categoryScores?: Record; + previousAttempts?: { score: number; date: string }[]; +} + +interface AssessmentSummaryCardProps { + result: AssessmentResult; + classAverage?: number; + onRetake?: () => void; + onDownloadReport?: () => void; + onViewCertificate?: () => void; + onReviewQuestion?: (questionId: string) => void; + compact?: boolean; +} + +const AssessmentSummaryCard: React.FC = ({ + result, + classAverage, + onRetake, + onDownloadReport, + onViewCertificate, + onReviewQuestion, + compact = false, +}) => { + const [showQuestions, setShowQuestions] = useState(false); + const [expandedQuestionId, setExpandedQuestionId] = useState(null); + const [filterType, setFilterType] = useState<'all' | 'correct' | 'incorrect'>('all'); + + // Calculate metrics + const metrics = useMemo(() => { + const avgTimePerQuestion = result.timeSpentMinutes * 60 / result.totalQuestions; + const accuracy = (result.correctAnswers / result.totalQuestions) * 100; + + // Find weakest category + let weakestCategory = ''; + let lowestScore = 100; + if (result.categoryScores) { + Object.entries(result.categoryScores).forEach(([cat, scores]) => { + const catScore = (scores.correct / scores.total) * 100; + if (catScore < lowestScore) { + lowestScore = catScore; + weakestCategory = cat; + } + }); + } + + // Calculate score trend + let scoreTrend = 0; + if (result.previousAttempts && result.previousAttempts.length > 0) { + const lastAttempt = result.previousAttempts[result.previousAttempts.length - 1]; + scoreTrend = result.score - lastAttempt.score; + } + + return { + accuracy, + avgTimePerQuestion, + weakestCategory, + weakestCategoryScore: lowestScore, + scoreTrend, + }; + }, [result]); + + const filteredQuestions = useMemo(() => { + return result.questions.filter(q => { + if (filterType === 'correct') return q.isCorrect; + if (filterType === 'incorrect') return !q.isCorrect; + return true; + }); + }, [result.questions, filterType]); + + const formatTime = (minutes: number) => { + if (minutes < 1) return `${Math.round(minutes * 60)}s`; + return `${Math.floor(minutes)}m ${Math.round((minutes % 1) * 60)}s`; + }; + + const getScoreColor = (score: number, passing: number) => { + if (score >= passing) return 'text-green-400'; + if (score >= passing * 0.8) return 'text-yellow-400'; + return 'text-red-400'; + }; + + const getScoreBg = (score: number, passing: number) => { + if (score >= passing) return 'from-green-500/20 to-emerald-500/20 border-green-500/30'; + if (score >= passing * 0.8) return 'from-yellow-500/20 to-amber-500/20 border-yellow-500/30'; + return 'from-red-500/20 to-rose-500/20 border-red-500/30'; + }; + + return ( +
+ {/* Header */} +
+
+
+ {result.passed ? ( + + ) : ( + + )} +
+
+

{result.title}

+

+ Intento {result.attemptNumber} + {result.maxAttempts && ` de ${result.maxAttempts}`} + {' • '} + {new Date(result.completedAt).toLocaleDateString('es-ES')} +

+
+
+ +
+ {onDownloadReport && ( + + )} +
+
+ + {/* Score Display */} +
+
+
+
+ + {result.score}% + + {metrics.scoreTrend !== 0 && ( + 0 ? 'text-green-400' : 'text-red-400'}`}> + {metrics.scoreTrend > 0 ? : } + {metrics.scoreTrend > 0 ? '+' : ''}{metrics.scoreTrend}% + + )} +
+

+ {result.passed ? 'Aprobado' : 'No aprobado'} • Minimo: {result.passingScore}% +

+
+ +
+ {result.passed && ( +
+ + +{result.xpEarned} XP +
+ )} + {classAverage && ( +

+ Promedio de la clase: {classAverage}% +

+ )} +
+
+
+ + {/* Stats Grid */} +
+
+
+ +
+
{result.correctAnswers}
+
Correctas
+
+
+
+ +
+
{result.incorrectAnswers}
+
Incorrectas
+
+
+
+ +
+
{formatTime(result.timeSpentMinutes)}
+
Tiempo total
+
+
+
+ +
+
{metrics.accuracy.toFixed(0)}%
+
Precision
+
+
+ + {/* Category Performance */} + {result.categoryScores && Object.keys(result.categoryScores).length > 0 && ( +
+

+ + Rendimiento por Categoria +

+
+ {Object.entries(result.categoryScores).map(([category, scores]) => { + const percentage = (scores.correct / scores.total) * 100; + return ( +
+
+ {category} + + {scores.correct}/{scores.total} ({percentage.toFixed(0)}%) + +
+
+
= result.passingScore ? 'bg-green-500' : 'bg-red-500' + }`} + style={{ width: `${percentage}%` }} + /> +
+
+ ); + })} +
+
+ )} + + {/* Weak Areas */} + {metrics.weakestCategory && metrics.weakestCategoryScore < result.passingScore && ( +
+
+ +
+

Area de mejora identificada

+

+ Tu puntaje mas bajo fue en {metrics.weakestCategory}{' '} + ({metrics.weakestCategoryScore.toFixed(0)}%). Te recomendamos revisar este tema. +

+
+
+
+ )} + + {/* Questions Review */} +
+ + + {showQuestions && ( +
+ {/* Filter */} +
+ {(['all', 'correct', 'incorrect'] as const).map((filter) => ( + + ))} +
+ + {/* Questions List */} +
+ {filteredQuestions.map((q, index) => { + const isExpanded = expandedQuestionId === q.id; + return ( +
+
setExpandedQuestionId(isExpanded ? null : q.id)} + className="flex items-start gap-3 cursor-pointer" + > +
+ {q.isCorrect ? ( + + ) : ( + + )} +
+
+

{q.questionText}

+
+ {q.category} + + {q.timeSpentSeconds}s +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {isExpanded && ( +
+
+ Tu respuesta: + + {q.userAnswer} + +
+ {!q.isCorrect && ( +
+ Respuesta correcta: + {q.correctAnswer} +
+ )} + {q.explanation && ( +
+ Explicacion: {q.explanation} +
+ )} +
+ )} +
+ ); + })} +
+
+ )} +
+ + {/* Previous Attempts Chart */} + {result.previousAttempts && result.previousAttempts.length > 0 && ( +
+

+ + Historial de Intentos +

+
+ {result.previousAttempts.map((attempt, index) => { + const height = (attempt.score / 100) * 100; + return ( +
+
= result.passingScore ? 'bg-green-500' : 'bg-red-500' + }`} + style={{ height: `${height}%` }} + /> + #{index + 1} +
+ ); + })} +
+
= result.passingScore ? 'bg-blue-500' : 'bg-red-500' + }`} + style={{ height: `${result.score}%` }} + /> + Actual +
+
+
+ )} + + {/* Actions */} +
+ {!result.passed && onRetake && ( + + )} + {result.passed && onViewCertificate && ( + + )} +
+
+ ); +}; + +export default AssessmentSummaryCard; diff --git a/src/modules/education/components/CourseProgressTracker.tsx b/src/modules/education/components/CourseProgressTracker.tsx new file mode 100644 index 0000000..82690c1 --- /dev/null +++ b/src/modules/education/components/CourseProgressTracker.tsx @@ -0,0 +1,310 @@ +/** + * CourseProgressTracker Component + * Comprehensive progress visualization at the course level + * OQI-002: Modulo Educativo + */ + +import React, { useMemo } from 'react'; +import { + BookOpen, + CheckCircle, + Circle, + Clock, + Play, + Lock, + Trophy, + Target, + TrendingUp, + Download, + ChevronRight, + Zap, + Calendar, +} from 'lucide-react'; + +export interface ModuleProgress { + id: string; + title: string; + lessonsTotal: number; + lessonsCompleted: number; + quizScore?: number; + quizPassed?: boolean; + timeSpentMinutes: number; + estimatedMinutes: number; + status: 'locked' | 'available' | 'in_progress' | 'completed'; + order: number; +} + +export interface CourseProgressData { + courseId: string; + courseTitle: string; + totalLessons: number; + completedLessons: number; + totalQuizzes: number; + passedQuizzes: number; + overallProgress: number; + xpEarned: number; + xpTotal: number; + timeSpentMinutes: number; + estimatedTotalMinutes: number; + startedAt?: string; + lastAccessedAt?: string; + modules: ModuleProgress[]; +} + +interface CourseProgressTrackerProps { + progress: CourseProgressData; + onModuleClick?: (moduleId: string) => void; + onDownloadReport?: () => void; + compact?: boolean; +} + +const CourseProgressTracker: React.FC = ({ + progress, + onModuleClick, + onDownloadReport, + compact = false, +}) => { + const sortedModules = useMemo(() => { + return [...progress.modules].sort((a, b) => a.order - b.order); + }, [progress.modules]); + + const completedModules = progress.modules.filter(m => m.status === 'completed').length; + const totalModules = progress.modules.length; + + const formatTime = (minutes: number) => { + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + }; + + const getStatusIcon = (status: ModuleProgress['status']) => { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ; + case 'locked': + return ; + default: + return ; + } + }; + + const getStatusBg = (status: ModuleProgress['status']) => { + switch (status) { + case 'completed': + return 'bg-green-500/10 border-green-500/30'; + case 'in_progress': + return 'bg-blue-500/10 border-blue-500/30'; + case 'locked': + return 'bg-gray-800/50 border-gray-700 opacity-60'; + default: + return 'bg-gray-800/50 border-gray-700'; + } + }; + + const estimatedCompletion = useMemo(() => { + const remainingMinutes = progress.estimatedTotalMinutes - progress.timeSpentMinutes; + if (remainingMinutes <= 0) return 'Completable ahora'; + const days = Math.ceil(remainingMinutes / 60 / 2); // Assuming 2 hours/day + return `~${days} ${days === 1 ? 'dia' : 'dias'} restantes`; + }, [progress]); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Progreso del Curso

+

{progress.courseTitle}

+
+
+ + {onDownloadReport && ( + + )} +
+ + {/* Overall Progress Bar */} +
+
+ Progreso General + {progress.overallProgress}% +
+
+
+
+
+ + {/* Stats Grid */} +
+
+
+ +
+
+ {progress.completedLessons}/{progress.totalLessons} +
+
Lecciones
+
+
+
+ +
+
+ {progress.passedQuizzes}/{progress.totalQuizzes} +
+
Quizzes
+
+
+
+ +
+
{formatTime(progress.timeSpentMinutes)}
+
Tiempo
+
+
+
+ +
+
{progress.xpEarned}
+
XP
+
+
+ + {/* Timeline Info */} + {(progress.startedAt || progress.lastAccessedAt) && ( +
+
+ + {progress.startedAt && ( + + Iniciado: {formatDate(progress.startedAt)} + + )} +
+
+ + {estimatedCompletion} +
+
+ )} + + {/* Module List */} +
+
+ Modulos ({completedModules}/{totalModules}) +
+ + {sortedModules.map((module, index) => { + const moduleProgress = + module.lessonsTotal > 0 + ? Math.round((module.lessonsCompleted / module.lessonsTotal) * 100) + : 0; + + return ( +
module.status !== 'locked' && onModuleClick?.(module.id)} + className={`p-3 rounded-lg border transition-all ${getStatusBg(module.status)} ${ + module.status !== 'locked' ? 'cursor-pointer hover:border-gray-600' : '' + }`} + > +
+ {/* Status Icon */} +
{getStatusIcon(module.status)}
+ + {/* Content */} +
+
+ + {index + 1}. {module.title} + + {module.status !== 'locked' && ( + + )} +
+ + {/* Progress Bar */} + {module.status !== 'locked' && ( +
+
+ + {module.lessonsCompleted}/{module.lessonsTotal} lecciones + + {moduleProgress}% +
+
+
+
+
+ )} + + {/* Quiz Badge */} + {module.quizScore !== undefined && ( +
+ + Quiz: {module.quizScore}% + + + {formatTime(module.timeSpentMinutes)} / {formatTime(module.estimatedMinutes)} + +
+ )} +
+
+
+ ); + })} +
+ + {/* Completion Message */} + {progress.overallProgress === 100 && ( +
+ +

Curso Completado!

+

Has ganado {progress.xpEarned} XP

+
+ )} +
+ ); +}; + +export default CourseProgressTracker; diff --git a/src/modules/education/components/LearningPathVisualizer.tsx b/src/modules/education/components/LearningPathVisualizer.tsx new file mode 100644 index 0000000..722bfa2 --- /dev/null +++ b/src/modules/education/components/LearningPathVisualizer.tsx @@ -0,0 +1,418 @@ +/** + * LearningPathVisualizer Component + * Visual roadmap showing recommended learning paths + * OQI-002: Modulo Educativo + */ + +import React, { useState, useMemo } from 'react'; +import { + Map, + BookOpen, + CheckCircle, + Circle, + Lock, + Play, + Star, + Clock, + ChevronRight, + ChevronDown, + ChevronUp, + Target, + Zap, + ArrowRight, + Trophy, + TrendingUp, +} from 'lucide-react'; + +export interface PathNode { + id: string; + title: string; + type: 'course' | 'module' | 'milestone'; + status: 'locked' | 'available' | 'in_progress' | 'completed'; + progress?: number; + xpReward: number; + estimatedMinutes: number; + difficulty: 'beginner' | 'intermediate' | 'advanced'; + prerequisites?: string[]; + description?: string; + thumbnail?: string; +} + +export interface LearningPath { + id: string; + title: string; + description: string; + totalXp: number; + earnedXp: number; + totalCourses: number; + completedCourses: number; + estimatedHours: number; + nodes: PathNode[]; +} + +interface LearningPathVisualizerProps { + path: LearningPath; + currentNodeId?: string; + onNodeClick?: (nodeId: string) => void; + onStartNext?: () => void; + compact?: boolean; +} + +const LearningPathVisualizer: React.FC = ({ + path, + currentNodeId, + onNodeClick, + onStartNext, + compact = false, +}) => { + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [viewMode, setViewMode] = useState<'timeline' | 'grid'>('timeline'); + + const overallProgress = useMemo(() => { + return Math.round((path.completedCourses / path.totalCourses) * 100); + }, [path]); + + const nextAvailableNode = useMemo(() => { + return path.nodes.find(n => n.status === 'available'); + }, [path.nodes]); + + const currentInProgress = useMemo(() => { + return path.nodes.find(n => n.status === 'in_progress'); + }, [path.nodes]); + + const toggleNode = (nodeId: string) => { + const newExpanded = new Set(expandedNodes); + if (newExpanded.has(nodeId)) { + newExpanded.delete(nodeId); + } else { + newExpanded.add(nodeId); + } + setExpandedNodes(newExpanded); + }; + + const getStatusIcon = (status: PathNode['status'], size = 5) => { + const sizeClass = `w-${size} h-${size}`; + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ; + case 'locked': + return ; + default: + return ; + } + }; + + const getDifficultyBadge = (difficulty: PathNode['difficulty']) => { + const colors = { + beginner: 'bg-green-500/20 text-green-400', + intermediate: 'bg-yellow-500/20 text-yellow-400', + advanced: 'bg-red-500/20 text-red-400', + }; + const labels = { + beginner: 'Principiante', + intermediate: 'Intermedio', + advanced: 'Avanzado', + }; + return ( + + {labels[difficulty]} + + ); + }; + + const formatTime = (minutes: number) => { + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{path.title}

+

{path.description}

+
+
+ + {!compact && ( +
+ + +
+ )} +
+ + {/* Overall Progress */} +
+
+
+ + {path.completedCourses}/{path.totalCourses} cursos + + + + {path.earnedXp}/{path.totalXp} XP + +
+ {overallProgress}% +
+
+
+
+
+ + {/* Current/Next Action */} + {(currentInProgress || nextAvailableNode) && ( +
+
+
+

+ {currentInProgress ? 'Continuar con' : 'Siguiente paso'} +

+

+ {(currentInProgress || nextAvailableNode)?.title} +

+
+ + + {formatTime((currentInProgress || nextAvailableNode)!.estimatedMinutes)} + + + + +{(currentInProgress || nextAvailableNode)!.xpReward} XP + +
+
+ +
+
+ )} + + {/* Path Visualization */} + {viewMode === 'timeline' ? ( +
+ {path.nodes.map((node, index) => { + const isExpanded = expandedNodes.has(node.id); + const isCurrent = node.id === currentNodeId; + const isLast = index === path.nodes.length - 1; + + return ( +
+ {/* Connector Line */} + {!isLast && ( +
+ )} + + {/* Node */} +
node.status !== 'locked' && onNodeClick?.(node.id)} + className={`relative flex items-start gap-3 p-3 rounded-lg transition-all ${ + node.status === 'locked' + ? 'opacity-50' + : 'cursor-pointer hover:bg-gray-800/50' + } ${isCurrent ? 'bg-blue-500/10 border border-blue-500/30' : ''}`} + > + {/* Status Icon */} +
+ {node.type === 'milestone' ? ( + + ) : ( + getStatusIcon(node.status) + )} +
+ + {/* Content */} +
+
+
+ + {node.title} + + {getDifficultyBadge(node.difficulty)} +
+ {node.status !== 'locked' && ( + + )} +
+ + {/* Quick Stats */} +
+ + + {formatTime(node.estimatedMinutes)} + + + + {node.xpReward} XP + + {node.progress !== undefined && node.progress > 0 && ( + + + {node.progress}% + + )} +
+ + {/* Progress Bar for In Progress */} + {node.status === 'in_progress' && node.progress !== undefined && ( +
+
+
+
+
+ )} + + {/* Expanded Details */} + {isExpanded && node.description && ( +
+

{node.description}

+ {node.prerequisites && node.prerequisites.length > 0 && ( +
+ Prerequisitos: +
+ {node.prerequisites.map((prereq) => ( + + {prereq} + + ))} +
+
+ )} +
+ )} +
+
+
+ ); + })} +
+ ) : ( + /* Grid View */ +
+ {path.nodes.map((node) => ( +
node.status !== 'locked' && onNodeClick?.(node.id)} + className={`p-4 rounded-lg border transition-all ${ + node.status === 'locked' + ? 'bg-gray-800/30 border-gray-700 opacity-50' + : node.status === 'completed' + ? 'bg-green-500/10 border-green-500/30 cursor-pointer hover:border-green-500/50' + : node.status === 'in_progress' + ? 'bg-blue-500/10 border-blue-500/30 cursor-pointer hover:border-blue-500/50' + : 'bg-gray-800/50 border-gray-700 cursor-pointer hover:border-gray-600' + }`} + > +
+ {node.type === 'milestone' ? ( + + ) : ( + getStatusIcon(node.status, 6) + )} + {getDifficultyBadge(node.difficulty)} +
+

+ {node.title} +

+
+ {formatTime(node.estimatedMinutes)} + + {node.xpReward} XP +
+ {node.status === 'in_progress' && node.progress !== undefined && ( +
+
+
+
+
+ )} +
+ ))} +
+ )} + + {/* Completion Badge */} + {overallProgress === 100 && ( +
+ +

Path Completado!

+

Has dominado {path.title}

+
+ )} +
+ ); +}; + +export default LearningPathVisualizer; diff --git a/src/modules/education/components/VideoProgressPlayer.tsx b/src/modules/education/components/VideoProgressPlayer.tsx new file mode 100644 index 0000000..372e5e8 --- /dev/null +++ b/src/modules/education/components/VideoProgressPlayer.tsx @@ -0,0 +1,554 @@ +/** + * VideoProgressPlayer Component + * Enhanced video player with advanced progress tracking + * OQI-002: Modulo Educativo + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + Play, + Pause, + Volume2, + VolumeX, + Maximize, + Minimize, + Settings, + SkipBack, + SkipForward, + Rewind, + FastForward, + Bookmark, + BookmarkCheck, + MessageSquare, + Repeat, + ChevronDown, + Clock, + Check, +} from 'lucide-react'; + +export interface VideoBookmark { + id: string; + time: number; + label: string; + createdAt: string; +} + +export interface VideoNote { + id: string; + time: number; + content: string; + createdAt: string; +} + +interface VideoProgressPlayerProps { + src: string; + poster?: string; + title?: string; + duration?: number; + initialProgress?: number; + bookmarks?: VideoBookmark[]; + notes?: VideoNote[]; + onProgressUpdate?: (progress: number, currentTime: number) => void; + onComplete?: () => void; + onBookmarkAdd?: (time: number, label: string) => void; + onBookmarkRemove?: (bookmarkId: string) => void; + onNoteAdd?: (time: number, content: string) => void; + autoResumePosition?: boolean; + compact?: boolean; +} + +const PLAYBACK_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; + +const VideoProgressPlayer: React.FC = ({ + src, + poster, + title, + duration: initialDuration, + initialProgress = 0, + bookmarks = [], + notes = [], + onProgressUpdate, + onComplete, + onBookmarkAdd, + onBookmarkRemove, + onNoteAdd, + autoResumePosition = true, + compact = false, +}) => { + const videoRef = useRef(null); + const progressRef = useRef(null); + const updateIntervalRef = useRef(null); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(initialDuration || 0); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [showSettings, setShowSettings] = useState(false); + const [showBookmarks, setShowBookmarks] = useState(false); + const [watchedPercentage, setWatchedPercentage] = useState(initialProgress); + const [isCompleted, setIsCompleted] = useState(false); + const [loopStart, setLoopStart] = useState(null); + const [loopEnd, setLoopEnd] = useState(null); + const [isLooping, setIsLooping] = useState(false); + const [showControls, setShowControls] = useState(true); + const [newBookmarkLabel, setNewBookmarkLabel] = useState(''); + const [showBookmarkInput, setShowBookmarkInput] = useState(false); + + // Format time as MM:SS or HH:MM:SS + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hrs > 0) { + return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Handle video metadata loaded + const handleLoadedMetadata = () => { + if (videoRef.current) { + setDuration(videoRef.current.duration); + if (autoResumePosition && initialProgress > 0) { + const resumeTime = (initialProgress / 100) * videoRef.current.duration; + videoRef.current.currentTime = resumeTime; + } + } + }; + + // Handle time update + const handleTimeUpdate = useCallback(() => { + if (!videoRef.current) return; + + const current = videoRef.current.currentTime; + setCurrentTime(current); + + // Calculate watched percentage + const percentage = (current / videoRef.current.duration) * 100; + setWatchedPercentage(Math.max(watchedPercentage, percentage)); + + // Check for loop + if (isLooping && loopEnd !== null && current >= loopEnd) { + videoRef.current.currentTime = loopStart || 0; + } + + // Check for completion (95% watched) + if (percentage >= 95 && !isCompleted) { + setIsCompleted(true); + onComplete?.(); + } + }, [watchedPercentage, isLooping, loopStart, loopEnd, isCompleted, onComplete]); + + // Periodic progress sync + useEffect(() => { + updateIntervalRef.current = setInterval(() => { + if (videoRef.current && isPlaying) { + onProgressUpdate?.(watchedPercentage, videoRef.current.currentTime); + } + }, 10000); // Sync every 10 seconds + + return () => { + if (updateIntervalRef.current) { + clearInterval(updateIntervalRef.current); + } + }; + }, [isPlaying, watchedPercentage, onProgressUpdate]); + + // Play/Pause toggle + const togglePlay = () => { + if (!videoRef.current) return; + + if (isPlaying) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + setIsPlaying(!isPlaying); + }; + + // Seek to position + const handleSeek = (e: React.MouseEvent) => { + if (!videoRef.current || !progressRef.current) return; + + const rect = progressRef.current.getBoundingClientRect(); + const percentage = (e.clientX - rect.left) / rect.width; + const newTime = percentage * duration; + videoRef.current.currentTime = newTime; + setCurrentTime(newTime); + }; + + // Skip forward/backward + const skip = (seconds: number) => { + if (!videoRef.current) return; + videoRef.current.currentTime = Math.max(0, Math.min(duration, currentTime + seconds)); + }; + + // Toggle mute + const toggleMute = () => { + if (!videoRef.current) return; + videoRef.current.muted = !isMuted; + setIsMuted(!isMuted); + }; + + // Change volume + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + if (videoRef.current) { + videoRef.current.volume = newVolume; + } + setVolume(newVolume); + setIsMuted(newVolume === 0); + }; + + // Change playback speed + const handleSpeedChange = (speed: number) => { + if (videoRef.current) { + videoRef.current.playbackRate = speed; + } + setPlaybackSpeed(speed); + setShowSettings(false); + }; + + // Toggle fullscreen + const toggleFullscreen = () => { + const container = videoRef.current?.parentElement?.parentElement; + if (!container) return; + + if (!isFullscreen) { + container.requestFullscreen?.(); + } else { + document.exitFullscreen?.(); + } + setIsFullscreen(!isFullscreen); + }; + + // Add bookmark + const handleAddBookmark = () => { + if (!newBookmarkLabel.trim()) return; + onBookmarkAdd?.(currentTime, newBookmarkLabel); + setNewBookmarkLabel(''); + setShowBookmarkInput(false); + }; + + // Jump to bookmark + const jumpToBookmark = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + setCurrentTime(time); + } + setShowBookmarks(false); + }; + + // Set loop points + const handleSetLoopStart = () => { + setLoopStart(currentTime); + if (loopEnd !== null && currentTime >= loopEnd) { + setLoopEnd(null); + } + }; + + const handleSetLoopEnd = () => { + if (loopStart !== null && currentTime > loopStart) { + setLoopEnd(currentTime); + setIsLooping(true); + } + }; + + const clearLoop = () => { + setLoopStart(null); + setLoopEnd(null); + setIsLooping(false); + }; + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + + switch (e.key.toLowerCase()) { + case ' ': + case 'k': + e.preventDefault(); + togglePlay(); + break; + case 'arrowleft': + case 'j': + skip(-10); + break; + case 'arrowright': + case 'l': + skip(10); + break; + case 'm': + toggleMute(); + break; + case 'f': + toggleFullscreen(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentTime, isPlaying, isMuted]); + + return ( +
setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + > + {/* Video Element */} +