Files
player/src/components/VideoElement.tsx
T
2025-10-29 14:22:08 +03:00

580 lines
17 KiB
TypeScript

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<VideoElementProps> = ({
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<number>(0)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
const [processedSubtitles, setProcessedSubtitles] = useState<SubtitleTrack[]>([])
const subtitleBlobUrlsRef = React.useRef<string[]>([])
// 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<HTMLVideoElement>) => {
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 (
<div className="video-container">
<video
ref={videoRef}
className="video-element"
poster={poster}
loop={loop}
muted={muted}
playsInline
preload="metadata"
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onEnded={handleEnded}
onError={handleError}
onClick={handleVideoClick}
>
{processedSubtitles.map((subtitle, index) => (
<track
key={index}
kind="subtitles"
src={subtitle.src}
srcLang={subtitle.lang}
label={subtitle.label}
default={subtitle.default}
/>
))}
</video>
</div>
)
}