analytica-frontend-lib
Version:
Repositório público dos componentes utilizados nas plataformas da Analytica Ensino
1 lines • 86.1 kB
Source Map (JSON)
{"version":3,"sources":["../../src/components/VideoPlayer/VideoPlayer.tsx","../../src/utils/utils.ts","../../src/components/IconButton/IconButton.tsx","../../src/components/Text/Text.tsx","../../src/hooks/useMobile.ts","../../src/components/DownloadButton/DownloadButton.tsx"],"sourcesContent":["import {\n useRef,\n useState,\n useEffect,\n useCallback,\n MouseEvent,\n KeyboardEvent,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport {\n Play,\n Pause,\n SpeakerHigh,\n SpeakerSlash,\n ArrowsOutSimple,\n ArrowsInSimple,\n ClosedCaptioning,\n DotsThreeVertical,\n} from 'phosphor-react';\nimport { cn } from '../../utils/utils';\nimport IconButton from '../IconButton/IconButton';\nimport Text from '../Text/Text';\nimport { useMobile } from '../../hooks/useMobile';\nimport DownloadButton, {\n DownloadContent,\n} from '../DownloadButton/DownloadButton';\n\n// Constants for timeout durations\nconst CONTROLS_HIDE_TIMEOUT = 3000; // 3 seconds for normal control hiding\nconst LEAVE_HIDE_TIMEOUT = 1000; // 1 second when mouse leaves the video area\nconst INIT_DELAY = 100; // ms delay to initialize controls on mount\n\n/**\n * VideoPlayer component props interface\n */\ninterface VideoPlayerProps {\n /** Video source URL */\n src: string;\n /** Video poster/thumbnail URL */\n poster?: string;\n /** Subtitles URL */\n subtitles?: string;\n /** Video title */\n title?: string;\n /** Video subtitle/description */\n subtitle?: string;\n /** Initial playback time in seconds */\n initialTime?: number;\n /** Callback fired when video time updates (seconds) */\n onTimeUpdate?: (seconds: number) => void;\n /** Callback fired with progress percentage (0-100) */\n onProgress?: (progress: number) => void;\n /** Callback fired when video completes (>95% watched) */\n onVideoComplete?: () => void;\n /** Additional CSS classes */\n className?: string;\n /** Auto-save progress to localStorage */\n autoSave?: boolean;\n /** localStorage key for saving progress */\n storageKey?: string;\n /** Download content URLs for lesson materials */\n downloadContent?: DownloadContent;\n /** Show download button in header */\n showDownloadButton?: boolean;\n /** Callback fired when download starts */\n onDownloadStart?: (contentType: string) => void;\n /** Callback fired when download completes */\n onDownloadComplete?: (contentType: string) => void;\n /** Callback fired when download fails */\n onDownloadError?: (contentType: string, error: Error) => void;\n}\n\n/**\n * Format seconds to MM:SS display format\n * @param seconds - Time in seconds\n * @returns Formatted time string\n */\nconst formatTime = (seconds: number): string => {\n if (!seconds || Number.isNaN(seconds)) return '0:00';\n const mins = Math.floor(seconds / 60);\n const secs = Math.floor(seconds % 60);\n return `${mins}:${secs.toString().padStart(2, '0')}`;\n};\n\n/**\n * Progress bar component props\n */\ninterface ProgressBarProps {\n currentTime: number;\n duration: number;\n progressPercentage: number;\n onSeek: (time: number) => void;\n}\n\n/**\n * Progress bar subcomponent\n */\nconst ProgressBar = ({\n currentTime,\n duration,\n progressPercentage,\n onSeek,\n className = 'px-4 pb-2',\n}: ProgressBarProps & { className?: string }) => (\n <div className={className}>\n <input\n type=\"range\"\n min={0}\n max={duration || 100}\n value={currentTime}\n onChange={(e) => onSeek(Number.parseFloat(e.target.value))}\n className=\"w-full h-1 bg-neutral-600 rounded-full appearance-none cursor-pointer slider:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500\"\n aria-label=\"Video progress\"\n style={{\n background: `linear-gradient(to right, var(--color-primary-700) ${progressPercentage}%, var(--color-secondary-300) ${progressPercentage}%)`,\n }}\n />\n </div>\n);\n\n/**\n * Volume controls component props\n */\ninterface VolumeControlsProps {\n volume: number;\n isMuted: boolean;\n onVolumeChange: (volume: number) => void;\n onToggleMute: () => void;\n iconSize?: number;\n showSlider?: boolean;\n}\n\n/**\n * Volume controls subcomponent\n */\nconst VolumeControls = ({\n volume,\n isMuted,\n onVolumeChange,\n onToggleMute,\n iconSize = 24,\n showSlider = true,\n}: VolumeControlsProps) => (\n <div className=\"flex items-center gap-2\">\n <IconButton\n icon={\n isMuted ? (\n <SpeakerSlash size={iconSize} />\n ) : (\n <SpeakerHigh size={iconSize} />\n )\n }\n onClick={onToggleMute}\n aria-label={isMuted ? 'Unmute' : 'Mute'}\n className=\"!bg-transparent !text-white hover:!bg-white/20\"\n />\n\n {showSlider && (\n <input\n type=\"range\"\n min={0}\n max={100}\n value={Math.round(volume * 100)}\n onChange={(e) => onVolumeChange(Number.parseInt(e.target.value))}\n className=\"w-20 h-1 bg-neutral-600 rounded-full appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500\"\n aria-label=\"Volume control\"\n style={{\n background: `linear-gradient(to right, var(--color-primary-700) ${volume * 100}%, var(--color-secondary-300) ${volume * 100}%)`,\n }}\n />\n )}\n </div>\n);\n\n/**\n * Speed menu component props\n */\ninterface SpeedMenuProps {\n showSpeedMenu: boolean;\n playbackRate: number;\n onToggleMenu: () => void;\n onSpeedChange: (speed: number) => void;\n isFullscreen: boolean;\n iconSize?: number;\n isTinyMobile?: boolean;\n}\n\n/**\n * Speed menu subcomponent\n */\nconst SpeedMenu = ({\n showSpeedMenu,\n playbackRate,\n onToggleMenu,\n onSpeedChange,\n isFullscreen,\n iconSize = 24,\n isTinyMobile = false,\n}: SpeedMenuProps) => {\n const buttonRef = useRef<HTMLButtonElement>(null);\n const speedMenuContainerRef = useRef<HTMLDivElement>(null);\n const speedMenuRef = useRef<HTMLDivElement>(null);\n\n const getMenuPosition = () => {\n if (!buttonRef.current) return { top: 0, left: 0 };\n const rect = buttonRef.current.getBoundingClientRect();\n\n // Adjust positioning for tiny mobile screens\n const menuHeight = isTinyMobile ? 150 : 180;\n const menuWidth = isTinyMobile ? 60 : 80;\n const padding = isTinyMobile ? 4 : 8;\n\n return {\n // Fixed coords are viewport-based — no scroll offsets.\n top: Math.max(padding, rect.top - menuHeight),\n left: Math.max(padding, rect.right - menuWidth),\n };\n };\n\n const position = getMenuPosition();\n\n useEffect(() => {\n const handleClickOutside = (event: Event) => {\n const target = event.target as Node;\n\n // Check if click is outside both the container and the menu\n const isOutsideContainer =\n speedMenuContainerRef.current &&\n !speedMenuContainerRef.current.contains(target);\n const isOutsideMenu =\n speedMenuRef.current && !speedMenuRef.current.contains(target);\n\n // Only close if click is outside both refs (null-safe checks)\n if (isOutsideContainer && isOutsideMenu) {\n onToggleMenu();\n }\n };\n\n if (showSpeedMenu) {\n document.addEventListener('mousedown', handleClickOutside);\n }\n\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n }, [showSpeedMenu, onToggleMenu]);\n\n const menuContent = (\n <div\n ref={speedMenuRef}\n role=\"menu\"\n aria-label=\"Playback speed\"\n className={\n isFullscreen\n ? 'absolute bottom-12 right-0 bg-background border border-border-100 rounded-lg shadow-lg p-2 min-w-24 z-[9999]'\n : 'fixed bg-background border border-border-100 rounded-lg shadow-lg p-2 min-w-24 z-[9999]'\n }\n style={\n isFullscreen\n ? undefined\n : {\n top: `${position.top}px`,\n left: `${position.left}px`,\n }\n }\n >\n {[0.5, 0.75, 1, 1.25, 1.5, 2].map((speed) => (\n <button\n key={speed}\n role=\"menuitemradio\"\n aria-checked={playbackRate === speed}\n onClick={() => onSpeedChange(speed)}\n className={`block w-full text-left px-3 py-1 text-sm rounded hover:bg-border-50 transition-colors ${\n playbackRate === speed\n ? 'bg-primary-950 text-secondary-100 font-medium'\n : 'text-text-950'\n }`}\n >\n {speed}x\n </button>\n ))}\n </div>\n );\n\n // SSR-safe portal content\n const portalContent =\n showSpeedMenu &&\n globalThis.window !== undefined &&\n globalThis.document !== undefined &&\n !!globalThis.document?.body\n ? createPortal(menuContent, globalThis.document.body)\n : null;\n\n return (\n <div className=\"relative\" ref={speedMenuContainerRef}>\n <IconButton\n ref={buttonRef}\n icon={<DotsThreeVertical size={iconSize} />}\n onClick={onToggleMenu}\n aria-label=\"Playback speed\"\n aria-haspopup=\"menu\"\n aria-expanded={showSpeedMenu}\n className=\"!bg-transparent !text-white hover:!bg-white/20\"\n />\n {showSpeedMenu && (isFullscreen ? menuContent : portalContent)}\n </div>\n );\n};\n\n/**\n * Video player component with controls and progress tracking\n * Integrates with backend lesson progress system\n *\n * @param props - VideoPlayer component props\n * @returns Video player element with controls\n */\nconst VideoPlayer = ({\n src,\n poster,\n subtitles,\n title,\n subtitle: subtitleText,\n initialTime = 0,\n onTimeUpdate,\n onProgress,\n onVideoComplete,\n className,\n autoSave = true,\n storageKey = 'video-progress',\n downloadContent,\n showDownloadButton = false,\n onDownloadStart,\n onDownloadComplete,\n onDownloadError,\n}: VideoPlayerProps) => {\n const videoRef = useRef<HTMLVideoElement>(null);\n const { isUltraSmallMobile, isTinyMobile } = useMobile();\n const [isPlaying, setIsPlaying] = useState(false);\n const [currentTime, setCurrentTime] = useState(0);\n const [duration, setDuration] = useState(0);\n const [isMuted, setIsMuted] = useState(false);\n const [volume, setVolume] = useState(1);\n const [isFullscreen, setIsFullscreen] = useState(false);\n const [showControls, setShowControls] = useState(true);\n const [hasCompleted, setHasCompleted] = useState(false);\n const [showCaptions, setShowCaptions] = useState(false);\n const [subtitlesValidation, setSubtitlesValidation] = useState<\n 'idle' | 'validating' | 'valid' | 'invalid'\n >('idle');\n\n // Reset completion flag when changing videos\n useEffect(() => {\n setHasCompleted(false);\n }, [src]);\n const [playbackRate, setPlaybackRate] = useState(1);\n const [showSpeedMenu, setShowSpeedMenu] = useState(false);\n const lastSaveTimeRef = useRef(0);\n const trackRef = useRef<HTMLTrackElement>(null);\n const controlsTimeoutRef = useRef<number | null>(null);\n const lastMousePositionRef = useRef({ x: 0, y: 0 });\n\n /**\n * Check if user is currently interacting with controls\n */\n const isUserInteracting = useCallback(() => {\n // Check if speed menu is open\n if (showSpeedMenu) {\n return true;\n }\n\n // Check if any control element has focus\n const activeElement = document.activeElement;\n const videoContainer = videoRef.current?.parentElement;\n\n if (activeElement && videoContainer?.contains(activeElement)) {\n // Ignore the video element itself - it should not prevent control hiding\n if (activeElement === videoRef.current) {\n return false;\n }\n\n // Check if focused element is a control (button, input, etc.)\n const isControl = activeElement.matches('button, input, [tabindex]');\n if (isControl) {\n return true;\n }\n }\n\n return false;\n }, [showSpeedMenu]);\n\n /**\n * Clear controls timeout\n */\n const clearControlsTimeout = useCallback(() => {\n if (controlsTimeoutRef.current) {\n clearTimeout(controlsTimeoutRef.current);\n controlsTimeoutRef.current = null;\n }\n }, []);\n\n /**\n * Show controls and set auto-hide timer\n */\n const showControlsWithTimer = useCallback(() => {\n setShowControls(true);\n clearControlsTimeout();\n\n // In fullscreen mode, only hide if video is playing\n if (isFullscreen) {\n if (isPlaying) {\n controlsTimeoutRef.current = globalThis.setTimeout(() => {\n setShowControls(false);\n }, CONTROLS_HIDE_TIMEOUT) as unknown as number;\n }\n } else {\n // In normal mode, always set a timer to hide controls\n controlsTimeoutRef.current = globalThis.setTimeout(() => {\n setShowControls(false);\n }, CONTROLS_HIDE_TIMEOUT) as unknown as number;\n }\n }, [isFullscreen, isPlaying, clearControlsTimeout]);\n\n /**\n * Handle mouse move with position detection\n */\n const handleMouseMove = useCallback(\n (event: MouseEvent) => {\n const currentX = event.clientX;\n const currentY = event.clientY;\n const lastPos = lastMousePositionRef.current;\n\n // Check if mouse actually moved (minimum 5px threshold)\n const hasMoved =\n Math.abs(currentX - lastPos.x) > 5 ||\n Math.abs(currentY - lastPos.y) > 5;\n\n if (hasMoved) {\n lastMousePositionRef.current = { x: currentX, y: currentY };\n showControlsWithTimer();\n }\n },\n [showControlsWithTimer]\n );\n\n /**\n * Handle mouse enter to show controls with appropriate timer logic\n */\n const handleMouseEnter = useCallback(() => {\n showControlsWithTimer();\n }, [showControlsWithTimer]);\n\n /**\n * Handle mouse leave to hide controls faster\n */\n const handleMouseLeave = useCallback(() => {\n const userInteracting = isUserInteracting();\n clearControlsTimeout();\n\n // Hide controls when mouse leaves, except when in fullscreen or user is interacting\n if (!isFullscreen && !userInteracting) {\n // Use shorter timeout when mouse leaves\n controlsTimeoutRef.current = globalThis.setTimeout(() => {\n setShowControls(false);\n }, LEAVE_HIDE_TIMEOUT) as unknown as number;\n }\n }, [isFullscreen, clearControlsTimeout, isUserInteracting]);\n\n /**\n * Initialize video element properties\n */\n useEffect(() => {\n // Set initial volume\n if (videoRef.current) {\n videoRef.current.volume = volume;\n videoRef.current.muted = isMuted;\n }\n }, [volume, isMuted]);\n\n /**\n * Synchronize isPlaying state with media events\n */\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n\n const onPlay = () => setIsPlaying(true);\n const onPause = () => setIsPlaying(false);\n const onEnded = () => setIsPlaying(false);\n\n video.addEventListener('play', onPlay);\n video.addEventListener('pause', onPause);\n video.addEventListener('ended', onEnded);\n\n return () => {\n video.removeEventListener('play', onPlay);\n video.removeEventListener('pause', onPause);\n video.removeEventListener('ended', onEnded);\n };\n }, []);\n\n /**\n * Set iOS/Safari inline playback attributes imperatively\n */\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n\n // Ensure inline playback on iOS/Safari\n video.setAttribute('playsinline', '');\n video.setAttribute('webkit-playsinline', '');\n }, []);\n\n /**\n * Handle controls auto-hide when play state changes\n */\n useEffect(() => {\n if (isPlaying) {\n // Start timer when video starts playing\n showControlsWithTimer();\n } else {\n // Keep controls visible when paused only in fullscreen\n clearControlsTimeout();\n if (isFullscreen) {\n setShowControls(true);\n } else {\n // In normal mode (not fullscreen), initialize timer even when paused\n // This ensures controls will hide properly from the start\n showControlsWithTimer();\n }\n }\n }, [isPlaying, isFullscreen, showControlsWithTimer, clearControlsTimeout]);\n\n /**\n * Handle fullscreen state changes from browser events\n */\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n\n const handleFullscreenChange = () => {\n const isCurrentlyFullscreen = !!document.fullscreenElement;\n setIsFullscreen(isCurrentlyFullscreen);\n\n // Show controls when entering fullscreen, hide after timeout if playing\n if (isCurrentlyFullscreen) {\n showControlsWithTimer();\n }\n };\n\n // Safari iOS-specific fullscreen event handlers\n const handleWebkitBeginFullscreen = () => {\n setIsFullscreen(true);\n showControlsWithTimer();\n };\n\n const handleWebkitEndFullscreen = () => {\n setIsFullscreen(false);\n };\n\n // Standard fullscreen events\n document.addEventListener('fullscreenchange', handleFullscreenChange);\n\n // Safari iOS fullscreen events\n video.addEventListener(\n 'webkitbeginfullscreen',\n handleWebkitBeginFullscreen\n );\n video.addEventListener('webkitendfullscreen', handleWebkitEndFullscreen);\n\n return () => {\n document.removeEventListener('fullscreenchange', handleFullscreenChange);\n video.removeEventListener(\n 'webkitbeginfullscreen',\n handleWebkitBeginFullscreen\n );\n video.removeEventListener(\n 'webkitendfullscreen',\n handleWebkitEndFullscreen\n );\n };\n }, [showControlsWithTimer]);\n\n /**\n * Initialize controls behavior on component mount\n * This ensures controls work correctly from the first load\n */\n useEffect(() => {\n const init = () => {\n if (!isFullscreen) {\n showControlsWithTimer();\n }\n };\n // Prefer rAF to avoid arbitrary timing if available; fall back to INIT_DELAY.\n let raf1 = 0,\n raf2 = 0,\n tid: number | undefined;\n if (globalThis.requestAnimationFrame === undefined) {\n tid = globalThis.setTimeout(init, INIT_DELAY) as unknown as number;\n return () => {\n if (tid) clearTimeout(tid);\n };\n } else {\n raf1 = requestAnimationFrame(() => {\n raf2 = requestAnimationFrame(init);\n });\n return () => {\n cancelAnimationFrame(raf1);\n cancelAnimationFrame(raf2);\n };\n }\n }, []); // Run only once on mount\n\n /**\n * Get initial time from props or localStorage\n */\n const getInitialTime = useCallback((): number | undefined => {\n if (!autoSave || !storageKey) {\n return Number.isFinite(initialTime) && initialTime >= 0\n ? initialTime\n : undefined;\n }\n\n const saved = Number(\n localStorage.getItem(`${storageKey}-${src}`) || Number.NaN\n );\n const hasValidInitial = Number.isFinite(initialTime) && initialTime >= 0;\n const hasValidSaved = Number.isFinite(saved) && saved >= 0;\n\n if (hasValidInitial) return initialTime;\n if (hasValidSaved) return saved;\n return undefined;\n }, [autoSave, storageKey, src, initialTime]);\n\n /**\n * Load saved progress from localStorage\n */\n useEffect(() => {\n const start = getInitialTime();\n if (start !== undefined && videoRef.current) {\n videoRef.current.currentTime = start;\n setCurrentTime(start);\n }\n }, [getInitialTime]);\n\n /**\n * Save progress to localStorage periodically\n */\n const saveProgress = useCallback(\n (time: number) => {\n if (!autoSave || !storageKey) return;\n\n const now = Date.now();\n if (now - lastSaveTimeRef.current > 5000) {\n localStorage.setItem(`${storageKey}-${src}`, time.toString());\n lastSaveTimeRef.current = now;\n }\n },\n [autoSave, storageKey, src]\n );\n\n /**\n * Handle play/pause toggle\n */\n const togglePlayPause = useCallback(async () => {\n const video = videoRef.current;\n if (!video) return;\n\n if (!video.paused) {\n video.pause();\n return;\n }\n\n try {\n await video.play();\n } catch {\n // Playback prevented (e.g., autoplay policy); keep state unchanged.\n }\n }, []);\n\n /**\n * Handle volume change\n */\n const handleVolumeChange = useCallback(\n (newVolume: number) => {\n const video = videoRef.current;\n if (!video) return;\n\n const volumeValue = newVolume / 100; // Convert 0-100 to 0-1\n video.volume = volumeValue;\n setVolume(volumeValue);\n\n // Auto mute/unmute based on volume\n const shouldMute = volumeValue === 0;\n const shouldUnmute = volumeValue > 0 && isMuted;\n\n if (shouldMute) {\n video.muted = true;\n setIsMuted(true);\n } else if (shouldUnmute) {\n video.muted = false;\n setIsMuted(false);\n }\n },\n [isMuted]\n );\n\n /**\n * Handle mute toggle\n */\n const toggleMute = useCallback(() => {\n const video = videoRef.current;\n if (!video) return;\n\n if (isMuted) {\n // Unmute: restore volume or set to 50% if it was 0\n const restoreVolume = volume > 0 ? volume : 0.5;\n video.volume = restoreVolume;\n video.muted = false;\n setVolume(restoreVolume);\n setIsMuted(false);\n } else {\n // Mute: set volume to 0 and mute\n video.muted = true;\n setIsMuted(true);\n }\n }, [isMuted, volume]);\n\n /**\n * Handle video seek\n */\n const handleSeek = useCallback((newTime: number) => {\n const video = videoRef.current;\n if (video) {\n video.currentTime = newTime;\n }\n }, []);\n\n /**\n * Detect if running on Safari iOS\n */\n const isSafariIOS = useCallback((): boolean => {\n const ua = navigator.userAgent;\n const isIOS = /iPad|iPhone|iPod/.test(ua);\n const isWebKit = /WebKit/.test(ua);\n const isNotChrome = !/CriOS|Chrome/.test(ua);\n return isIOS && isWebKit && isNotChrome;\n }, []);\n\n /**\n * Handle fullscreen toggle\n */\n const toggleFullscreen = useCallback(() => {\n const video = videoRef.current;\n const container = video?.parentElement;\n if (!video || !container) return;\n\n // Safari iOS requires using webkitEnterFullscreen on video element\n if (isSafariIOS()) {\n // Type assertion for Safari-specific API\n const videoElement = video as HTMLVideoElement & {\n webkitEnterFullscreen?: () => void;\n webkitExitFullscreen?: () => void;\n webkitDisplayingFullscreen?: boolean;\n };\n\n if (!isFullscreen && videoElement.webkitEnterFullscreen) {\n videoElement.webkitEnterFullscreen();\n } else if (isFullscreen && videoElement.webkitExitFullscreen) {\n videoElement.webkitExitFullscreen();\n }\n } else if (!isFullscreen && container.requestFullscreen) {\n // Standard fullscreen API for other browsers\n container.requestFullscreen();\n } else if (isFullscreen && document.exitFullscreen) {\n document.exitFullscreen();\n }\n }, [isFullscreen, isSafariIOS]);\n\n /**\n * Handle playback speed change\n */\n const handleSpeedChange = useCallback((speed: number) => {\n if (videoRef.current) {\n videoRef.current.playbackRate = speed;\n setPlaybackRate(speed);\n setShowSpeedMenu(false);\n }\n }, []);\n\n /**\n * Toggle speed menu visibility\n */\n const toggleSpeedMenu = useCallback(() => {\n setShowSpeedMenu(!showSpeedMenu);\n }, [showSpeedMenu]);\n\n /**\n * Toggle captions visibility\n */\n const toggleCaptions = useCallback(() => {\n if (\n !trackRef.current?.track ||\n !subtitles ||\n subtitlesValidation !== 'valid'\n )\n return;\n\n const newShowCaptions = !showCaptions;\n setShowCaptions(newShowCaptions);\n\n // Control track mode programmatically - we already validated subtitles above\n trackRef.current.track.mode = newShowCaptions ? 'showing' : 'hidden';\n }, [showCaptions, subtitles, subtitlesValidation]);\n\n /**\n * Check video completion and fire callback\n */\n const checkVideoCompletion = useCallback(\n (progressPercent: number) => {\n if (progressPercent >= 95 && !hasCompleted) {\n setHasCompleted(true);\n onVideoComplete?.();\n }\n },\n [hasCompleted, onVideoComplete]\n );\n\n /**\n * Handle time update\n */\n const handleTimeUpdate = useCallback(() => {\n const video = videoRef.current;\n if (!video) return;\n\n const current = video.currentTime;\n setCurrentTime(current);\n\n // Save progress periodically\n saveProgress(current);\n\n // Fire callbacks\n onTimeUpdate?.(current);\n\n if (duration > 0) {\n const progressPercent = (current / duration) * 100;\n onProgress?.(progressPercent);\n checkVideoCompletion(progressPercent);\n }\n }, [duration, saveProgress, onTimeUpdate, onProgress, checkVideoCompletion]);\n\n /**\n * Handle loaded metadata\n */\n const handleLoadedMetadata = useCallback(() => {\n if (videoRef.current) {\n setDuration(videoRef.current.duration);\n }\n }, []);\n\n /**\n * Validate subtitles URL before showing the button\n */\n useEffect(() => {\n const controller = new AbortController();\n\n const validateSubtitles = async () => {\n // If no subtitles, mark as idle\n if (!subtitles) {\n setSubtitlesValidation('idle');\n return;\n }\n\n // Start validation\n setSubtitlesValidation('validating');\n\n try {\n // Check if it's a data URL (inline VTT)\n if (subtitles.startsWith('data:')) {\n setSubtitlesValidation('valid');\n return;\n }\n\n // Fetch the subtitles file to validate it\n const response = await fetch(subtitles, {\n method: 'HEAD',\n signal: controller.signal,\n });\n\n if (response.ok) {\n // Optionally check content type\n const contentType = response.headers.get('content-type');\n const isValidType =\n !contentType ||\n contentType.includes('text/vtt') ||\n contentType.includes('text/plain') ||\n contentType.includes('application/octet-stream');\n\n if (isValidType) {\n setSubtitlesValidation('valid');\n } else {\n setSubtitlesValidation('invalid');\n console.warn(\n `Subtitles URL has invalid content type: ${contentType}`\n );\n }\n } else {\n setSubtitlesValidation('invalid');\n console.warn(\n `Subtitles URL returned status: ${response.status} ${response.statusText}`\n );\n }\n } catch (error) {\n // Ignore AbortError - it's expected when cleaning up\n if (error instanceof Error && error.name === 'AbortError') {\n return;\n }\n console.warn('Subtitles URL validation failed:', error);\n setSubtitlesValidation('invalid');\n }\n };\n\n validateSubtitles();\n\n // Cleanup: abort ongoing fetch to prevent stale updates\n return () => {\n controller.abort();\n };\n }, [subtitles]);\n\n /**\n * Initialize track mode when track is available\n */\n useEffect(() => {\n if (trackRef.current?.track) {\n // Set initial mode based on showCaptions state and subtitle availability\n trackRef.current.track.mode =\n showCaptions && subtitles && subtitlesValidation === 'valid'\n ? 'showing'\n : 'hidden';\n }\n }, [subtitles, showCaptions, subtitlesValidation]);\n\n /**\n * Handle visibility change and blur to pause video when losing focus\n */\n useEffect(() => {\n const handleVisibilityChange = () => {\n if (document.hidden && isPlaying && videoRef.current) {\n videoRef.current.pause();\n setIsPlaying(false);\n }\n };\n\n const handleBlur = () => {\n if (isPlaying && videoRef.current) {\n videoRef.current.pause();\n setIsPlaying(false);\n }\n };\n\n document.addEventListener('visibilitychange', handleVisibilityChange);\n globalThis.addEventListener('blur', handleBlur);\n\n return () => {\n document.removeEventListener('visibilitychange', handleVisibilityChange);\n globalThis.removeEventListener('blur', handleBlur);\n // Clean up timers on unmount\n clearControlsTimeout();\n };\n }, [isPlaying, clearControlsTimeout]);\n\n const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;\n\n /**\n * Get responsive icon size based on screen size\n */\n const getIconSize = useCallback(() => {\n if (isTinyMobile) return 18;\n if (isUltraSmallMobile) return 20;\n return 24;\n }, [isTinyMobile, isUltraSmallMobile]);\n\n /**\n * Get responsive padding classes for controls\n */\n const getControlsPadding = useCallback(() => {\n if (isTinyMobile) return 'px-2 pb-2 pt-1';\n if (isUltraSmallMobile) return 'px-3 pb-3 pt-1';\n return 'px-4 pb-4';\n }, [isTinyMobile, isUltraSmallMobile]);\n\n /**\n * Get responsive gap classes for controls\n */\n const getControlsGap = useCallback(() => {\n if (isTinyMobile) return 'gap-1';\n if (isUltraSmallMobile) return 'gap-2';\n return 'gap-4';\n }, [isTinyMobile, isUltraSmallMobile]);\n\n /**\n * Get responsive padding for progress bar\n */\n const getProgressBarPadding = useCallback(() => {\n if (isTinyMobile) return 'px-2 pb-1';\n if (isUltraSmallMobile) return 'px-3 pb-1';\n return 'px-4 pb-2';\n }, [isTinyMobile, isUltraSmallMobile]);\n\n /**\n * Get responsive positioning classes for center play button\n */\n const getCenterPlayButtonPosition = useCallback(() => {\n if (isTinyMobile) return 'items-center justify-center -translate-y-12';\n if (isUltraSmallMobile) return 'items-center justify-center -translate-y-8';\n return 'items-center justify-center';\n }, [isTinyMobile, isUltraSmallMobile]);\n\n /**\n * Calculate top controls opacity based on state\n */\n const getTopControlsOpacity = useCallback(() => {\n return showControls ? 'opacity-100' : 'opacity-0';\n }, [showControls]);\n\n /**\n * Calculate bottom controls opacity based on state\n */\n const getBottomControlsOpacity = useCallback(() => {\n return showControls ? 'opacity-100' : 'opacity-0';\n }, [showControls]);\n\n /**\n * Seek video backward\n */\n const seekBackward = useCallback(() => {\n if (videoRef.current) {\n videoRef.current.currentTime -= 10;\n }\n }, []);\n\n /**\n * Seek video forward\n */\n const seekForward = useCallback(() => {\n if (videoRef.current) {\n videoRef.current.currentTime += 10;\n }\n }, []);\n\n /**\n * Increase volume\n */\n const increaseVolume = useCallback(() => {\n handleVolumeChange(Math.min(100, volume * 100 + 10));\n }, [handleVolumeChange, volume]);\n\n /**\n * Decrease volume\n */\n const decreaseVolume = useCallback(() => {\n handleVolumeChange(Math.max(0, volume * 100 - 10));\n }, [handleVolumeChange, volume]);\n\n /**\n * Handle video element keyboard events\n */\n const handleVideoKeyDown = useCallback(\n (e: KeyboardEvent) => {\n if (!e.key) return;\n\n // Prevent bubbling to parent handlers to avoid double toggles\n e.stopPropagation();\n showControlsWithTimer();\n\n // Map of key handlers for better maintainability and reduced complexity\n const keyHandlers: Record<string, () => void | Promise<void>> = {\n ' ': togglePlayPause,\n Enter: togglePlayPause,\n ArrowLeft: seekBackward,\n ArrowRight: seekForward,\n ArrowUp: increaseVolume,\n ArrowDown: decreaseVolume,\n m: toggleMute,\n M: toggleMute,\n f: toggleFullscreen,\n F: toggleFullscreen,\n };\n\n const handler = keyHandlers[e.key];\n if (handler) {\n e.preventDefault();\n handler();\n }\n },\n [\n showControlsWithTimer,\n togglePlayPause,\n seekBackward,\n seekForward,\n increaseVolume,\n decreaseVolume,\n toggleMute,\n toggleFullscreen,\n ]\n );\n\n const groupedSubTitleValid = subtitles && subtitlesValidation === 'valid';\n\n return (\n <div className={cn('flex flex-col', className)}>\n {/* Integrated Header */}\n {(title || subtitleText) && (\n <div className=\"bg-subject-1 px-8 py-4 flex items-end justify-between min-h-20\">\n <div className=\"flex flex-col gap-1\">\n {title && (\n <Text\n as=\"h2\"\n size=\"lg\"\n weight=\"bold\"\n color=\"text-text-900\"\n className=\"leading-5 tracking-wide\"\n >\n {title}\n </Text>\n )}\n {subtitleText && (\n <Text\n as=\"p\"\n size=\"sm\"\n weight=\"normal\"\n color=\"text-text-600\"\n className=\"leading-5\"\n >\n {subtitleText}\n </Text>\n )}\n </div>\n\n {/* Download Button */}\n {showDownloadButton && downloadContent && (\n <DownloadButton\n content={downloadContent}\n lessonTitle={title}\n onDownloadStart={onDownloadStart}\n onDownloadComplete={onDownloadComplete}\n onDownloadError={onDownloadError}\n className=\"flex-shrink-0\"\n />\n )}\n </div>\n )}\n\n {/* Video Container */}\n <section\n className={cn(\n 'relative w-full bg-background overflow-hidden group',\n 'rounded-b-xl',\n // Hide cursor when controls are hidden and video is playing\n isPlaying && !showControls\n ? 'cursor-none group-hover:cursor-default'\n : 'cursor-default'\n )}\n aria-label={title ? `Video player: ${title}` : 'Video player'}\n onMouseMove={handleMouseMove}\n onMouseEnter={handleMouseEnter}\n onTouchStart={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n {/* Video Element */}\n <video\n ref={videoRef}\n src={src}\n poster={poster}\n className=\"w-full h-full object-contain analytica-video\"\n controlsList=\"nodownload\"\n playsInline\n onTimeUpdate={handleTimeUpdate}\n onLoadedMetadata={handleLoadedMetadata}\n onClick={togglePlayPause}\n onKeyDown={handleVideoKeyDown}\n tabIndex={0}\n aria-label={title ? `Video: ${title}` : 'Video player'}\n >\n <track\n ref={trackRef}\n kind=\"captions\"\n src={\n groupedSubTitleValid\n ? subtitles\n : 'data:text/vtt;charset=utf-8,WEBVTT'\n }\n srcLang=\"pt-br\"\n label={\n groupedSubTitleValid\n ? 'Legendas em Português'\n : 'Sem legendas disponíveis'\n }\n default={false}\n />\n </video>\n\n {/* Center Play Button */}\n {!isPlaying && (\n <div\n className={cn(\n 'absolute inset-0 flex bg-black/30 transition-opacity',\n getCenterPlayButtonPosition()\n )}\n >\n <IconButton\n icon={<Play size={32} weight=\"regular\" className=\"ml-1\" />}\n onClick={togglePlayPause}\n aria-label=\"Play video\"\n className=\"!bg-transparent !text-white !w-auto !h-auto hover:!bg-transparent hover:!text-gray-200\"\n />\n </div>\n )}\n\n {/* Top Controls */}\n <div\n className={cn(\n 'absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity',\n getTopControlsOpacity()\n )}\n >\n <div className=\"flex justify-start\">\n <IconButton\n icon={\n isFullscreen ? (\n <ArrowsInSimple size={24} />\n ) : (\n <ArrowsOutSimple size={24} />\n )\n }\n onClick={toggleFullscreen}\n aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}\n className=\"!bg-transparent !text-white hover:!bg-white/20\"\n />\n </div>\n </div>\n\n {/* Bottom Controls */}\n <div\n className={cn(\n 'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent transition-opacity',\n getBottomControlsOpacity()\n )}\n >\n {/* Progress Bar */}\n <ProgressBar\n currentTime={currentTime}\n duration={duration}\n progressPercentage={progressPercentage}\n onSeek={handleSeek}\n className={getProgressBarPadding()}\n />\n\n {/* Control Buttons */}\n <div\n className={cn(\n 'flex items-center justify-between',\n getControlsPadding()\n )}\n >\n {/* Left Controls */}\n <div className={cn('flex items-center', getControlsGap())}>\n {/* Play/Pause */}\n <IconButton\n icon={\n isPlaying ? (\n <Pause size={getIconSize()} />\n ) : (\n <Play size={getIconSize()} />\n )\n }\n onClick={togglePlayPause}\n aria-label={isPlaying ? 'Pause' : 'Play'}\n className=\"!bg-transparent !text-white hover:!bg-white/20\"\n />\n\n {/* Volume */}\n <VolumeControls\n volume={volume}\n isMuted={isMuted}\n onVolumeChange={handleVolumeChange}\n onToggleMute={toggleMute}\n iconSize={getIconSize()}\n showSlider={!isUltraSmallMobile}\n />\n\n {/* Captions - Only show after validation is complete and valid */}\n {groupedSubTitleValid && (\n <IconButton\n icon={<ClosedCaptioning size={getIconSize()} />}\n onClick={toggleCaptions}\n aria-label={showCaptions ? 'Hide captions' : 'Show captions'}\n className={cn(\n '!bg-transparent hover:!bg-white/20',\n showCaptions ? '!text-primary-400' : '!text-white'\n )}\n />\n )}\n\n {/* Time Display */}\n <Text size=\"sm\" weight=\"medium\" color=\"text-white\">\n {formatTime(currentTime)} / {formatTime(duration)}\n </Text>\n </div>\n\n {/* Right Controls */}\n <div className=\"flex items-center gap-4\">\n {/* Speed Control */}\n <SpeedMenu\n showSpeedMenu={showSpeedMenu}\n playbackRate={playbackRate}\n onToggleMenu={toggleSpeedMenu}\n onSpeedChange={handleSpeedChange}\n iconSize={getIconSize()}\n isTinyMobile={isTinyMobile}\n isFullscreen={isFullscreen}\n />\n </div>\n </div>\n </div>\n </section>\n </div>\n );\n};\n\nexport default VideoPlayer;\n","import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\nexport { syncDropdownState } from './dropdown';\nexport {\n getSelectedIdsFromCategories,\n toggleArrayItem,\n toggleSingleValue,\n areFiltersEqual,\n} from './activityFilters';\nexport {\n mapQuestionTypeToEnum,\n mapQuestionTypeToEnumRequired,\n} from './questionTypeUtils';\nexport {\n getStatusBadgeConfig,\n formatTimeSpent,\n formatQuestionNumbers,\n formatDateToBrazilian,\n} from './activityDetailsUtils';\n\n/**\n * Retorna a cor hexadecimal com opacidade 0.3 (4d) se não estiver em dark mode.\n * Se estiver em dark mode, retorna a cor original.\n *\n * @param hexColor - Cor hexadecimal (ex: \"#0066b8\" ou \"0066b8\")\n * @param isDark - booleano indicando se está em dark mode\n * @returns string - cor hexadecimal com opacidade se necessário\n */\nexport function getSubjectColorWithOpacity(\n hexColor: string | undefined,\n isDark: boolean\n): string | undefined {\n if (!hexColor) return undefined;\n // Remove o '#' se existir\n let color = hexColor.replace(/^#/, '').toLowerCase();\n\n if (isDark) {\n // Se está em dark mode, sempre remove opacidade se existir\n if (color.length === 8) {\n color = color.slice(0, 6);\n }\n return `#${color}`;\n } else {\n // Se não está em dark mode (light mode)\n let resultColor: string;\n if (color.length === 6) {\n // Adiciona opacidade 0.3 (4D) para cores de 6 dígitos\n resultColor = `#${color}4d`;\n } else if (color.length === 8) {\n // Já tem opacidade, retorna como está\n resultColor = `#${color}`;\n } else {\n // Para outros tamanhos (3, 4, 5 dígitos), retorna como está\n resultColor = `#${color}`;\n }\n return resultColor;\n }\n}\n","import { ButtonHTMLAttributes, ReactNode, forwardRef } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * IconButton component props interface\n */\nexport type IconButtonProps = {\n /** Ícone a ser exibido no botão */\n icon: ReactNode;\n /** Tamanho do botão */\n size?: 'sm' | 'md';\n /** Estado de seleção/ativo do botão - permanece ativo até ser clicado novamente ou outro botão ser ativado */\n active?: boolean;\n /** Additional CSS classes to apply */\n className?: string;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\n/**\n * IconButton component for Analytica Ensino platforms\n *\n * Um botão compacto apenas com ícone, ideal para menus dropdown,\n * barras de ferramentas e ações secundárias.\n * Oferece dois tamanhos com estilo consistente.\n * Estado ativo permanece até ser clicado novamente ou outro botão ser ativado.\n * Suporta forwardRef para acesso programático ao elemento DOM.\n *\n * @param icon - O ícone a ser exibido no botão\n * @param size - Tamanho do botão (sm, md)\n * @param active - Estado ativo/selecionado do botão\n * @param className - Classes CSS adicionais\n * @param props - Todos os outros atributos HTML padrão de button\n * @returns Um elemento button compacto estilizado apenas com ícone\n *\n * @example\n * ```tsx\n * <IconButton\n * icon={<MoreVerticalIcon />}\n * size=\"sm\"\n * onClick={() => openMenu()}\n * />\n * ```\n *\n * @example\n * ```tsx\n * // Botão ativo em uma barra de ferramentas - permanece ativo até outro clique\n * <IconButton\n * icon={<BoldIcon />}\n * active={isBold}\n * onClick={toggleBold}\n * />\n * ```\n *\n * @example\n * ```tsx\n * // Usando ref para controle programático\n * const buttonRef = useRef<HTMLButtonElement>(null);\n *\n * <IconButton\n * ref={buttonRef}\n * icon={<EditIcon />}\n * size=\"md\"\n * onClick={() => startEditing()}\n * />\n * ```\n */\nconst IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(\n (\n { icon, size = 'md', active = false, className = '', disabled, ...props },\n ref\n ) => {\n // Classes base para todos os estados\n const baseClasses = [\n 'inline-flex',\n 'items-center',\n 'justify-center',\n 'rounded-lg',\n 'font-medium',\n 'bg-transparent',\n 'text-text-950',\n 'cursor-pointer',\n 'hover:bg-primary-600',\n 'hover:text-text',\n 'focus-visible:outline-none',\n 'focus-visible:ring-2',\n 'focus-visible:ring-offset-0',\n 'focus-visible:ring-indicator-info',\n 'disabled:opacity-50',\n 'disabled:cursor-not-allowed',\n 'disabled:pointer-events-none',\n ];\n\n // Classes de tamanho\n const sizeClasses = {\n sm: ['w-6', 'h-6', 'text-sm'],\n md: ['w-10', 'h-10', 'text-base'],\n };\n\n // Classes de estado ativo\n const activeClasses = active\n ? ['!bg-primary-50', '!text-primary-950', 'hover:!bg-primary-100']\n : [];\n\n const allClasses = [\n ...baseClasses,\n ...sizeClasses[size],\n ...activeClasses,\n ].join(' ');\n\n // Garantir acessibilidade com aria-label padrão\n const ariaLabel = props['aria-label'] ?? 'Botão de ação';\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(allClasses, className)}\n disabled={disabled}\n aria-pressed={active}\n aria-label={ariaLabel}\n {...props}\n >\n <span className=\"flex items-center justify-center\">{icon}</span>\n </button>\n );\n }\n);\n\nIconButton.displayName = 'IconButton';\n\nexport default IconButton;\n","import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';\nimport { cn } from '../../utils/utils';\n\n/**\n * Base text component props\n */\ntype BaseTextProps = {\n /** Content to be displayed */\n children?: ReactNode;\n /** Text size variant */\n size?:\n | '2xs'\n | 'xs'\n | 'sm'\n | 'md'\n | 'lg'\n | 'xl'\n | '2xl'\n | '3xl'\n | '4xl'\n | '5xl'\n | '6xl';\n /** Font weight variant */\n weight?:\n | 'hairline'\n | 'light'\n | 'normal'\n | 'medium'\n | 'semibold'\n | 'bold'\n | 'extrabold'\n | 'black';\n /** Color variant - white for light backgrounds, black for dark backgrounds */\n color?: string;\n /** Additional CSS classes to apply */\n className?: string;\n};\n\n/**\n * Polymorphic text component props that ensures type safety based on the 'as' prop\n */\ntype TextProps<T extends ElementType = 'p'> = BaseTextProps & {\n /** HTML tag to render */\n as?: T;\n} & Omit<ComponentPropsWithoutRef<T>, keyof BaseTextProps>;\n\n/**\n * Text component for Analytica Ensino platforms\n *\n * A flexible polymorphic text component with multiple sizes, weights, and colors.\n * Automatically adapts to dark and light themes with full type safety.\n *\n * @param children - The content to display\n * @param size - The text size variant (2xs, xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl)\n * @param weight - The font weight variant (hairline, light, normal, medium, semibold, bold, extrabold, black)\n * @param color - The color variant - adapts to theme\n * @param as - The HTML tag to render - determines allowed attributes via TypeScript\n * @param className - Additional CSS classes\n * @param props - HTML attributes valid for the chosen tag only\n * @returns A styled text element with type-safe attributes\n *\n * @example\n * ```tsx\n * <Text size=\"lg\" weight=\"bold\" color=\"text-info-800\">\n * This is a large, bold text\n * </Text>\n *\n * <Text as=\"a\" href=\"/link\" target=\"_blank\">\n * Link with type-safe anchor attributes\n * </Text>\n *\n * <Text as=\"button\" onClick={handleClick} disabled>\n * Button with type-safe button attributes\n * </Text>\n * ```\n */\nconst Text = <T extends ElementType = 'p'>({\n children,\n size = 'md',\n weight = 'normal',\n color = 'text-text-950',\n as,\n className = '',\n ...props\n}: TextProps<T>) => {\n let sizeClasses = '';\n let weightClasses = '';\n\n // Text size classes mapping\n const sizeClassMap = {\n '2xs': 'text-2xs',\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-md',\n lg: 'text-lg',\n xl: 'text-xl',\n '2xl': 'text-2xl',\n '3xl': 'text-3xl',\n '4xl': 'text-4xl',\n '5xl': 'text-5xl',\n '6xl': 'text-6xl',\n } as const;\n\n sizeClasses = sizeClassMap[size] ?? sizeClassMap.md;\n\n // Font weight classes mapping\n const weightClassMap = {\n hairline: 'font-hairline',\n light: 'font-light',\n normal: 'font-normal',\n medium: 'font-medium',\n semibold: 'font-semibold',\n bold: 'font-bold',\n extrabold: 'font-extrabold',\n black: 'font-black',\n } as const;\n\n weightClasses = weightClassMap[weight] ?? weightClassMap.normal;\n\n const baseClasses = 'font-primary';\n const Component = as ?? ('p' as ElementType);\n\n return (\n <Component\n className={cn(baseClasses, sizeClasses, weightClasses, color, className)}\n {...props}\n >\n {children}\n </Component>\n );\n};\n\nexport default Text;\n","import { useState, useEffect } from 'react';\n\n// Mobile width in pixels\nconst MOBILE_WIDTH = 500;\n// Tablet width in pixels\nconst TABLET_WIDTH = 931;\n// Video responsive breakpoints\nconst SMALL_MOBILE_WIDTH = 425;\nconst EXTRA_SMALL_MOBILE_WIDTH = 375;\nconst ULTRA_SMALL_MOBILE_WIDTH = 375; // For video controls\nconst TINY_MOBILE_WIDTH = 320;\n// Default desktop width for SSR\nconst DEFAULT_WIDTH = 1200;\n\n/**\n * Device type based on screen width\n */\nexport type DeviceType = 'responsive' | 'desktop';\n\n/**\n * Gets the window width safely (SSR compatible)\n * @returns {number} window width or default value for SSR\n */\nconst getWindowWidth = (): number => {\n if (typeof window === 'undefined') {\n return DEFAULT_WIDTH;\n }\n return window.innerWidth;\n};\n\n/**\n * Gets the current device type based on screen width\n * @returns {DeviceType} 'responsive' for mobile/tablet (width < 931px), 'desktop' for larger screens\n */\nexport const getDeviceType = (): DeviceType => {\n const width = getWindowWidth();\n return width < TABLET_WIDTH ? 'responsive' : 'desktop';\n};\