Files
player/src/components/VideoElement.tsx
T

819 lines
25 KiB
TypeScript

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<VideoElementProps> = ({
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<number>(0)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
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
// 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<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 }))
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 (
<div className="video-container">
<video
ref={videoRef}
className="video-element"
poster={poster}
loop={loop}
muted={muted}
crossOrigin={crossOrigin}
playsInline={playsInline}
preload={preload}
controlsList={controlsList}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={handleDurationChange}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onProgress={handleProgress}
onRateChange={handleRateChange}
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>
)
}