import React, { useEffect, useCallback, useState } from 'react' import { usePlayerContext } from '../contexts/PlayerContext' import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types' import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper' import { setHlsAudioTrack, setHlsQualityLevel } from '../utils/hlsControl' import { setupHlsInstance } from '../utils/hlsSetup' import { createSubtitleBlobURL } from '../utils/subtitles' 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 onQualityLevelsLoaded?: (qualities: VideoQuality[]) => 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, onQualityLevelsLoaded, }) => { const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext() const lastClickTimeRef = React.useRef(0) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) const [availableQualities, setAvailableQualities] = 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 setVideoState((prev) => ({ ...prev, duration: video.duration, volume: video.volume, muted: video.muted, })) // Enable default subtitle if specified const tracks = video.textTracks if (tracks && processedSubtitles.length > 0) { const defaultSubtitle = processedSubtitles.find((sub) => sub.default) if (defaultSubtitle) { // Find the corresponding track and set it as showing for (let i = 0; i < tracks.length; i++) { const track = tracks[i] if (track.language === defaultSubtitle.lang) { track.mode = 'showing' setSubtitle(defaultSubtitle) break } } } } onLoadedMetadata?.() }, [videoRef, setVideoState, onLoadedMetadata, processedSubtitles, setSubtitle]) 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]) // Process subtitles - convert SRT to VTT blob URLs useEffect(() => { // Clean up old blob URLs subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)) subtitleBlobUrlsRef.current = [] if (subtitles.length === 0) { setProcessedSubtitles([]) return } const processSubtitles = async () => { const processed = await Promise.all( subtitles.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) console.log(`Processed SRT subtitle "${subtitle.label}": ${subtitle.src} -> ${blobUrl}`) return { ...subtitle, src: blobUrl } } // VTT files can be used directly console.log(`Using VTT subtitle "${subtitle.label}": ${subtitle.src}`) return subtitle } catch (error) { console.error(`Failed to process subtitle ${subtitle.label}:`, error) return subtitle } }) ) setProcessedSubtitles(processed) } processSubtitles() // Cleanup function return () => { subtitleBlobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)) subtitleBlobUrlsRef.current = [] } }, [subtitles]) 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 HLS source and load hls.js if needed useEffect(() => { const video = videoRef.current if (!video) return setAvailableAudioTracks([]) onAudioTracksLoaded?.([]) setAvailableQualities([]) onQualityLevelsLoaded?.([]) // 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') === '') { try { cleanupFn = await setupHlsInstance({ video, src, autoplay, onAudioTracksLoaded: (tracks) => { setAvailableAudioTracks(tracks) onAudioTracksLoaded?.(tracks) }, onQualityLevelsLoaded: (qualities) => { setAvailableQualities(qualities) onQualityLevelsLoaded?.(qualities) }, onError: handleError, }) } 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 { 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, onQualityLevelsLoaded, ]) // 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 // 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) { track.mode = 'showing' console.log(`Enabled subtitle track: ${track.label} (${track.language})`) break } } } }, [settings.subtitle, videoRef]) // Debug: Monitor text track loading useEffect(() => { const video = videoRef.current if (!video) return const handleTrackLoad = (e: Event) => { const track = e.target as HTMLTrackElement console.log(`Track loaded: ${track.label} (${track.srclang})`, track.readyState) } const handleTrackError = (e: Event) => { const track = e.target as HTMLTrackElement console.error(`Track error: ${track.label} (${track.srclang})`, track.track.cues?.length) } const trackElements = video.querySelectorAll('track') trackElements.forEach((track) => { track.addEventListener('load', handleTrackLoad) track.addEventListener('error', handleTrackError) }) // Also monitor text tracks const textTracks = video.textTracks const handleCueChange = () => { for (let i = 0; i < textTracks.length; i++) { const track = textTracks[i] if (track.mode === 'showing') { console.log(`Active track: ${track.label}, cues: ${track.cues?.length || 0}, active cues: ${track.activeCues?.length || 0}`) } } } for (let i = 0; i < textTracks.length; i++) { textTracks[i].addEventListener('cuechange', handleCueChange) } return () => { trackElements.forEach((track) => { track.removeEventListener('load', handleTrackLoad) track.removeEventListener('error', handleTrackError) }) for (let i = 0; i < textTracks.length; i++) { textTracks[i].removeEventListener('cuechange', handleCueChange) } } }, [videoRef, processedSubtitles]) return (
) }