381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
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<VideoElementProps> = ({
|
|
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<number>(0)
|
|
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
|
|
|
// 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<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])
|
|
|
|
// 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 (
|
|
<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}
|
|
>
|
|
{subtitles.map((subtitle, index) => (
|
|
<track
|
|
key={index}
|
|
kind="subtitles"
|
|
src={subtitle.src}
|
|
srcLang={subtitle.lang}
|
|
label={subtitle.label}
|
|
default={subtitle.default}
|
|
/>
|
|
))}
|
|
</video>
|
|
</div>
|
|
)
|
|
}
|