580 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|