819 lines
25 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|