import React, { useEffect, useCallback, useState } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' import type { SubtitleTrack, AudioTrack, VideoQuality, VideoProtocol } from '../types' import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper' import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl' import { setupHlsInstance } from '../utils/hlsSetup' import { setupRtmpInstance } from '../utils/rtmpSetup' import { setupMpegtsInstance } from '../utils/mpegtsSetup' import { detectVideoProtocol } from '../utils/videoProtocol' import { createSubtitleBlobURL } from '../utils/subtitles' import { logger } from '../utils/logger' import './VideoElement.css' interface VideoElementProps { src: string poster?: string protocol?: 'auto' | VideoProtocol autoplay?: boolean loop?: boolean muted?: boolean volume?: number playbackRate?: number currentTime?: number crossOrigin?: '' | 'anonymous' | 'use-credentials' preload?: 'none' | 'metadata' | 'auto' playsInline?: boolean controlsList?: string 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 onProgress?: (buffered: number) => void onDurationChange?: (duration: number) => void onRateChange?: (playbackRate: number) => void onFullscreenChange?: (isFullscreen: boolean) => void onPictureInPictureChange?: (isPictureInPicture: boolean) => void onWaiting?: () => void onCanPlay?: () => void onAudioTracksLoaded?: (tracks: AudioTrack[]) => void onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void } export const VideoElement: React.FC = ({ src, poster, protocol = 'auto', autoplay = false, loop = false, muted = false, volume, playbackRate, currentTime: initialCurrentTime, crossOrigin, preload = 'metadata', playsInline = true, controlsList, subtitles = [], onPlay, onPause, onEnded, onTimeUpdate, onVolumeChange, onError, onLoadedMetadata, onSeeking, onSeeked, onProgress, onDurationChange, onRateChange, onFullscreenChange, onPictureInPictureChange, onWaiting, onCanPlay, onAudioTracksLoaded, onQualityLevelsLoaded, onSubtitleTracksLoaded, }) => { const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext() const lastClickTimeRef = React.useRef(0) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) const [availableQualities, setAvailableQualities] = useState([]) const [hlsSubtitles, setHlsSubtitles] = useState([]) const [processedSubtitles, setProcessedSubtitles] = useState([]) const subtitleBlobUrlsRef = React.useRef([]) // 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 // Check if this is a live broadcast (duration is Infinity for live streams) const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0 logger.log('[VideoElement] Is live broadcast?', isLiveBroadcast, 'duration:', video.duration) setVideoState((prev) => ({ ...prev, duration: video.duration, volume: video.volume, muted: video.muted, isLiveBroadcast, })) // Enable default subtitle if specified const tracks = video.textTracks if (tracks && processedSubtitles.length > 0) { const defaultSubtitle = processedSubtitles.find((sub) => sub.default) if (defaultSubtitle) { logger.log(`🎯 Found default subtitle in metadata: ${defaultSubtitle.label}`) // Set subtitle in context (this will trigger the useEffect that enables it) setSubtitle(defaultSubtitle) } } onLoadedMetadata?.() }, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle]) const handleDurationChange = useCallback(() => { const video = videoRef.current if (!video) return // Re-check if this is a live broadcast when duration changes const isLiveBroadcast = !isFinite(video.duration) || video.duration === 0 logger.log('[VideoElement] Duration changed. Is live broadcast?', isLiveBroadcast, 'duration:', video.duration) setVideoState((prev) => ({ ...prev, duration: video.duration, isLiveBroadcast, })) onDurationChange?.(video.duration) }, [videoRef, setVideoState, onDurationChange]) 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 })) onWaiting?.() }, [setVideoState, onWaiting]) const handleCanPlay = useCallback(() => { setVideoState((prev) => ({ ...prev, loading: false })) onCanPlay?.() }, [setVideoState, onCanPlay]) const handleProgress = useCallback(() => { const video = videoRef.current if (!video) return const buffered = video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1) : 0 onProgress?.(buffered) }, [videoRef, onProgress]) const handleRateChange = useCallback(() => { const video = videoRef.current if (!video) return setVideoState((prev) => ({ ...prev, playbackRate: video.playbackRate })) onRateChange?.(video.playbackRate) }, [videoRef, setVideoState, onRateChange]) 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 })) onFullscreenChange?.(isFullscreen) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange) } }, [setVideoState, onFullscreenChange]) // Handle PIP changes useEffect(() => { const handlePIPChange = () => { const isPIP = !!document.pictureInPictureElement setVideoState((prev) => ({ ...prev, pictureInPicture: isPIP })) onPictureInPictureChange?.(isPIP) } document.addEventListener('enterpictureinpicture', handlePIPChange) document.addEventListener('leavepictureinpicture', handlePIPChange) return () => { document.removeEventListener('enterpictureinpicture', handlePIPChange) document.removeEventListener('leavepictureinpicture', handlePIPChange) } }, [setVideoState, onPictureInPictureChange]) // Apply volume prop to video element useEffect(() => { const video = videoRef.current if (!video || volume === undefined) return // Clamp volume between 0 and 1 const clampedVolume = Math.max(0, Math.min(1, volume)) if (video.volume !== clampedVolume) { video.volume = clampedVolume } }, [volume, videoRef]) // Apply playbackRate prop to video element useEffect(() => { const video = videoRef.current if (!video || playbackRate === undefined) return if (video.playbackRate !== playbackRate) { video.playbackRate = playbackRate } }, [playbackRate, videoRef]) // Apply currentTime prop to video element (only once on mount or when it changes) useEffect(() => { const video = videoRef.current if (!video || initialCurrentTime === undefined) return // Only seek if the difference is significant (more than 1 second) if (Math.abs(video.currentTime - initialCurrentTime) > 1) { video.currentTime = initialCurrentTime } }, [initialCurrentTime, videoRef]) // Process subtitles - convert SRT to VTT blob URLs and merge with HLS subtitles useEffect(() => { let cancelled = false // Clean up old blob URLs subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)) subtitleBlobUrlsRef.current = [] // Merge manual subtitles and HLS subtitles const allSubtitles = [...subtitles, ...hlsSubtitles] if (allSubtitles.length === 0) { setProcessedSubtitles([]) return } const processSubtitles = async () => { const processed = await Promise.all( allSubtitles.map(async (subtitle) => { try { // Check if it's an SRT file if (subtitle.src.endsWith('.srt')) { // Fetch and convert SRT to VTT const response = await fetch(subtitle.src) if (!response.ok) { throw new Error(`Failed to fetch subtitle: ${response.status} ${response.statusText}`) } const srtContent = await response.text() const blobUrl = createSubtitleBlobURL(srtContent, 'srt') subtitleBlobUrlsRef.current.push(blobUrl) return { ...subtitle, src: blobUrl } } // VTT files can be used directly return subtitle } catch (error) { logger.error(`Failed to process subtitle ${subtitle.label}:`, error) return subtitle } }) ) // Only update state if not cancelled if (!cancelled) { setProcessedSubtitles(processed) } } processSubtitles() // Cleanup function return () => { cancelled = true subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)) subtitleBlobUrlsRef.current = [] } }, [subtitles, hlsSubtitles]) useEffect(() => { const video = videoRef.current if (!video) return if (processedSubtitles.length === 0) return if (settings.subtitle) return const defaultSubtitle = processedSubtitles.find((subtitle) => subtitle.default) if (!defaultSubtitle) return const tracks = video.textTracks if (!tracks || tracks.length === 0) return for (let i = 0; i < tracks.length; i++) { const track = tracks[i] if (track.language === defaultSubtitle.lang) { track.mode = 'showing' setSubtitle(defaultSubtitle) break } } }, [processedSubtitles, settings.subtitle, setSubtitle, videoRef]) // Detect video protocol and setup appropriate player useEffect(() => { const video = videoRef.current if (!video) return let isCancelled = false setAvailableAudioTracks([]) onAudioTracksLoaded?.([]) setAvailableQualities([]) onQualityLevelsLoaded?.([]) setHlsSubtitles([]) onSubtitleTracksLoaded?.([]) // 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 } // Detect video protocol const detectedProtocol = detectVideoProtocol(src) const detection = protocol === 'auto' ? detectedProtocol : { ...detectedProtocol, protocol, needsSpecialPlayer: protocol !== 'native', } let cleanupFn: (() => void) | null = null const teardownPlayer = () => { if (cleanupFn) { cleanupFn() cleanupFn = null } // Also check for any lingering player instances if ((video as any).__hlsInstance) { const hls = (video as any).__hlsInstance if (hls && typeof hls.destroy === 'function') { hls.destroy() } delete (video as any).__hlsInstance } if ((video as any).__rtmpInstance) { const rtmp = (video as any).__rtmpInstance if (rtmp && typeof rtmp.destroy === 'function') { rtmp.destroy() } delete (video as any).__rtmpInstance } if ((video as any).__mpegtsInstance) { const mpegts = (video as any).__mpegtsInstance if (mpegts && typeof mpegts.destroy === 'function') { mpegts.destroy() } delete (video as any).__mpegtsInstance } } logger.log('[VideoElement] Source:', src) logger.log('[VideoElement] Detected protocol:', detection.protocol) logger.log('[VideoElement] Is live stream?', detection.isLive) logger.log('[VideoElement] Needs special player?', detection.needsSpecialPlayer) const setupPlayer = async () => { try { switch (detection.protocol) { case 'hls': { // HLS streaming setup const canPlayHLS = video.canPlayType('application/vnd.apple.mpegurl') const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) const shouldUseHlsJs = canPlayHLS === '' || !isSafari logger.log('[VideoElement] Native HLS support?', canPlayHLS) logger.log('[VideoElement] Is Safari?', isSafari) logger.log('[VideoElement] Will use HLS.js?', shouldUseHlsJs) if (shouldUseHlsJs) { logger.log('[VideoElement] Setting up HLS.js...') cleanupFn = await setupHlsInstance({ video, src, autoplay, onAudioTracksLoaded: (tracks) => { if (isCancelled) return setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) }, onQualityLevelsLoaded: (qualities) => { if (isCancelled) return setAvailableQualities(qualities) onQualityLevelsLoaded?.(qualities) }, onSubtitleTracksLoaded: (tracks) => { if (isCancelled) return setHlsSubtitles(tracks) onSubtitleTracksLoaded?.(tracks) }, onError: handleError, }) if (isCancelled) { teardownPlayer() return } } else { if (isCancelled) return logger.log('[VideoElement] Using native HLS playback') video.src = src if (autoplay) { void video.play().catch(() => undefined) } } break } case 'rtmp': { // RTMP/FLV streaming setup logger.log('[VideoElement] Setting up RTMP/FLV player...') cleanupFn = await setupRtmpInstance({ video, src, autoplay, onError: handleError, onLoadedMetadata, }) if (isCancelled) { teardownPlayer() return } break } case 'mpegts': { // MPEG-TS/IPTV streaming setup logger.log('[VideoElement] Setting up MPEG-TS player...') cleanupFn = await setupMpegtsInstance({ video, src, autoplay, onError: handleError, onLoadedMetadata, }) if (isCancelled) { teardownPlayer() return } break } case 'dash': { // DASH streaming - not yet implemented if (isCancelled) return const error = new Error('DASH streaming is not yet supported') logger.error('[VideoElement]', error.message) setVideoState((prev) => ({ ...prev, error, loading: false })) onError?.(error) break } case 'native': default: { // Native HTML5 video (MP4, WebM, etc.) if (isCancelled) return logger.log('[VideoElement] Using native video.src') video.src = src if (autoplay) { void video.play().catch(() => undefined) } break } } } 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 ${detection.protocol.toUpperCase()} video`) } if (isCancelled) return logger.error('[VideoElement] Setup error:', error) setVideoState((prev) => ({ ...prev, error, loading: false, })) onError?.(error) } } void setupPlayer() // Cleanup function return () => { isCancelled = true teardownPlayer() } }, [ src, protocol, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded, onQualityLevelsLoaded, onSubtitleTracksLoaded, onLoadedMetadata, ]) // 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]) // Reset selected quality if it no longer exists useEffect(() => { if (!settings.quality) return if (availableQualities.length === 0) return const hasQuality = availableQualities.some((quality) => { if ( typeof settings.quality?.levelIndex === 'number' && typeof quality.levelIndex === 'number' && quality.levelIndex === settings.quality.levelIndex ) { return true } if ( typeof settings.quality?.height === 'number' && typeof quality.height === 'number' && quality.height === settings.quality.height ) { return true } return quality.label === settings.quality?.label }) if (!hasQuality) { setQuality(null) } }, [availableQualities, settings.quality, setQuality]) // Apply selected quality to HLS instance useEffect(() => { const video = videoRef.current if (!video) return const hlsInstance = (video as any).__hlsInstance if (!hlsInstance) return if (!settings.quality) { setHlsQualityLevel(hlsInstance, null) return } let targetLevelIndex = typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined if (typeof targetLevelIndex !== 'number') { const matchingQuality = availableQualities.find((quality) => { if ( typeof settings.quality?.levelIndex === 'number' && typeof quality.levelIndex === 'number' ) { return quality.levelIndex === settings.quality.levelIndex } if ( typeof settings.quality?.height === 'number' && typeof quality.height === 'number' ) { return quality.height === settings.quality.height } return quality.label === settings.quality?.label }) if (matchingQuality && typeof matchingQuality.levelIndex === 'number') { targetLevelIndex = matchingQuality.levelIndex } } setHlsQualityLevel(hlsInstance, targetLevelIndex) }, [settings.quality, availableQualities, videoRef]) // Handle subtitle track changes useEffect(() => { const video = videoRef.current if (!video) return const tracks = video.textTracks if (!tracks || tracks.length === 0) return const enableSubtitle = () => { // Disable all tracks first for (let i = 0; i < tracks.length; i++) { tracks[i].mode = 'hidden' } // Enable the selected subtitle track if (settings.subtitle) { for (let i = 0; i < tracks.length; i++) { const track = tracks[i] if (track.language === settings.subtitle.lang) { // Wait for track to have cues before showing if (track.cues && track.cues.length > 0) { track.mode = 'showing' logger.log(`🔊 Enabled subtitle track: ${track.label} (${track.language})`) logger.log(` - cues available: ${track.cues.length}`) logger.log(` - track.mode: ${track.mode}`) } else { logger.warn(`⚠️ Track ${track.label} has no cues yet, waiting...`) // Track not ready yet, will be handled by load event track.mode = 'showing' } break } } } } // Try to enable immediately enableSubtitle() // Also listen for track load events to retry const handleTrackChange = () => { logger.log(`🔄 Track changed, re-enabling subtitle`) enableSubtitle() } for (let i = 0; i < tracks.length; i++) { tracks[i].addEventListener('load', handleTrackChange) } return () => { for (let i = 0; i < tracks.length; i++) { tracks[i].removeEventListener('load', handleTrackChange) } } }, [settings.subtitle, videoRef]) return (
) }