import React, { useEffect, useCallback, useState } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' import type { SubtitleTrack, AudioTrack } from '../types' import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper' import { getHlsAudioTracks, setHlsAudioTrack } from '../utils/hlsLoader' import './VideoElement.css' interface VideoElementProps { src: string poster?: string autoplay?: boolean loop?: boolean muted?: boolean subtitles?: SubtitleTrack[] onPlay?: () => void onPause?: () => void onEnded?: () => void onTimeUpdate?: (currentTime: number) => void onVolumeChange?: (volume: number) => void onError?: (error: Error) => void onLoadedMetadata?: () => void onSeeking?: () => void onSeeked?: () => void onAudioTracksLoaded?: (tracks: AudioTrack[]) => void } export const VideoElement: React.FC = ({ src, poster, autoplay = false, loop = false, muted = false, subtitles = [], onPlay, onPause, onEnded, onTimeUpdate, onVolumeChange, onError, onLoadedMetadata, onSeeking, onSeeked, onAudioTracksLoaded, }) => { const { videoRef, setVideoState, toggleFullscreen, settings } = usePlayerContext() const lastClickTimeRef = React.useRef(0) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) // Handle video events const handlePlay = useCallback(() => { setVideoState((prev) => ({ ...prev, playing: true })) onPlay?.() }, [setVideoState, onPlay]) const handlePause = useCallback(() => { setVideoState((prev) => ({ ...prev, playing: false })) onPause?.() }, [setVideoState, onPause]) const handleTimeUpdate = useCallback(() => { const video = videoRef.current if (!video) return const currentTime = video.currentTime const buffered = video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1) : 0 setVideoState((prev) => ({ ...prev, currentTime, buffered, })) onTimeUpdate?.(currentTime) }, [videoRef, setVideoState, onTimeUpdate]) const handleLoadedMetadata = useCallback(() => { const video = videoRef.current if (!video) return setVideoState((prev) => ({ ...prev, duration: video.duration, volume: video.volume, muted: video.muted, })) onLoadedMetadata?.() }, [videoRef, setVideoState, onLoadedMetadata]) const handleVolumeChange = useCallback(() => { const video = videoRef.current if (!video) return setVideoState((prev) => ({ ...prev, volume: video.volume, muted: video.muted, })) onVolumeChange?.(video.volume) }, [videoRef, setVideoState, onVolumeChange]) const handleSeeking = useCallback(() => { setVideoState((prev) => ({ ...prev, seeking: true })) onSeeking?.() }, [setVideoState, onSeeking]) const handleSeeked = useCallback(() => { setVideoState((prev) => ({ ...prev, seeking: false })) onSeeked?.() }, [setVideoState, onSeeked]) const handleWaiting = useCallback(() => { setVideoState((prev) => ({ ...prev, loading: true })) }, [setVideoState]) const handleCanPlay = useCallback(() => { setVideoState((prev) => ({ ...prev, loading: false })) }, [setVideoState]) const handleEnded = useCallback(() => { setVideoState((prev) => ({ ...prev, playing: false })) onEnded?.() }, [setVideoState, onEnded]) const handleError = useCallback(() => { const video = videoRef.current if (!video || !video.error) return let errorMessage = `Video error: ${video.error.message}` // Check if it's a CORS-related error const videoError = video.error if ( videoError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED || videoError.code === MediaError.MEDIA_ERR_NETWORK ) { // Could be a CORS issue errorMessage = getCORSErrorMessage(video.src || src) } const error = new Error(errorMessage) setVideoState((prev) => ({ ...prev, error, loading: false })) onError?.(error) }, [videoRef, setVideoState, onError, src]) // Handle double-click on video for fullscreen toggle const handleVideoClick = useCallback( (e: React.MouseEvent) => { const now = Date.now() const timeSinceLastClick = now - lastClickTimeRef.current if (timeSinceLastClick < 300) { // Double click - toggle fullscreen e.preventDefault() toggleFullscreen() lastClickTimeRef.current = 0 } else { // Single click - record time lastClickTimeRef.current = now } }, [toggleFullscreen] ) // Handle fullscreen changes useEffect(() => { const handleFullscreenChange = () => { const isFullscreen = !!document.fullscreenElement setVideoState((prev) => ({ ...prev, fullscreen: isFullscreen })) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange) } }, [setVideoState]) // Handle PIP changes useEffect(() => { const handlePIPChange = () => { const isPIP = !!document.pictureInPictureElement setVideoState((prev) => ({ ...prev, pictureInPicture: isPIP })) } document.addEventListener('enterpictureinpicture', handlePIPChange) document.addEventListener('leavepictureinpicture', handlePIPChange) return () => { document.removeEventListener('enterpictureinpicture', handlePIPChange) document.removeEventListener('leavepictureinpicture', handlePIPChange) } }, [setVideoState]) // Detect HLS source and load hls.js if needed useEffect(() => { const video = videoRef.current if (!video) return // Validate video URL first const validation = validateVideoURL(src) if (!validation.valid) { const error = new Error(validation.error || 'Invalid video URL') setVideoState((prev) => ({ ...prev, error, loading: false })) onError?.(error) return } const isHLS = src.includes('.m3u8') let cleanupFn: (() => void) | null = null const setupHls = async () => { if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') { // Browser doesn't support HLS natively, load hls.js try { // Dynamic import with CDN fallback const { loadHls, isHlsSupported } = await import('../utils/hlsLoader') const Hls = await loadHls() if (!isHlsSupported(Hls)) { throw new Error('HLS.js is not supported in this browser') } const hls = new Hls({ enableWorker: true, lowLatencyMode: false, }) hls.loadSource(src) hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, () => { // Extract audio tracks after manifest is parsed // Sometimes audio tracks are not immediately available, so we try with a small delay setTimeout(() => { const tracks = getHlsAudioTracks(hls) if (tracks.length > 0) { setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) } }, 100) if (autoplay) { void video.play().catch(() => undefined) } }) // Also listen to audio track updates hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => { const tracks = getHlsAudioTracks(hls) if (tracks.length > 0) { setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) } }) hls.on(Hls.Events.ERROR, (_event: any, data: any) => { if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad() break case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError() break default: handleError() break } } }) // Store hls instance for cleanup ;(video as any).__hlsInstance = hls // Setup cleanup function cleanupFn = () => { if (hls) { hls.destroy() } delete (video as any).__hlsInstance } } catch (err) { let error: Error if (err instanceof Error && isCORSError(err)) { const corsMessage = getCORSErrorMessage(src) error = new Error(corsMessage) } else { error = err instanceof Error ? err : new Error('Failed to load HLS') } setVideoState((prev) => ({ ...prev, error, loading: false, })) onError?.(error) } } else { // Native support or regular video video.src = src if (autoplay) { void video.play().catch(() => undefined) } } } setupHls() // Cleanup function return () => { if (cleanupFn) { cleanupFn() } // Also check for any lingering HLS instance if ((video as any).__hlsInstance) { const hls = (video as any).__hlsInstance if (hls && typeof hls.destroy === 'function') { hls.destroy() } delete (video as any).__hlsInstance } } }, [src, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded]) // Handle audio track changes useEffect(() => { const video = videoRef.current if (!video || !settings.audioTrack) return const hlsInstance = (video as any).__hlsInstance if (!hlsInstance) return // Find the index of the selected audio track const trackIndex = availableAudioTracks.findIndex( (track) => track.language === settings.audioTrack?.language ) if (trackIndex !== -1) { setHlsAudioTrack(hlsInstance, trackIndex) } }, [settings.audioTrack, availableAudioTracks, videoRef]) return (
) }