Initial commit: modern React video player library
Add all source files for a feature-rich, reusable video player built with React, TypeScript, and Vite. Includes core components, context, hooks, utilities, styles, demo app, and configuration files.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
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, containerRef, 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
|
||||
const corsMessage = getCORSErrorMessage(video.src || src)
|
||||
console.error(corsMessage)
|
||||
errorMessage = `Failed to load video. This might be a CORS issue. Check console for details.`
|
||||
}
|
||||
|
||||
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()
|
||||
console.log('🖱️ [Video Click] Double-click detected, toggling fullscreen')
|
||||
toggleFullscreen()
|
||||
lastClickTimeRef.current = 0
|
||||
} else {
|
||||
// Single click - record time
|
||||
lastClickTimeRef.current = now
|
||||
console.log('🖱️ [Video Click] Single click detected')
|
||||
}
|
||||
},
|
||||
[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
|
||||
}
|
||||
|
||||
// Log warning if external URL
|
||||
if (validation.warning) {
|
||||
console.warn(`⚠️ [Video] ${validation.warning}`)
|
||||
}
|
||||
|
||||
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, () => {
|
||||
console.log('✅ [HLS] Manifest parsed successfully')
|
||||
|
||||
// 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) {
|
||||
console.log(`✅ [HLS] Found ${tracks.length} audio tracks:`, tracks)
|
||||
setAvailableAudioTracks(tracks)
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
} else {
|
||||
console.log('ℹ️ [HLS] No audio tracks found in manifest')
|
||||
}
|
||||
}, 100)
|
||||
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
console.warn('⚠️ [HLS] Autoplay prevented:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Also listen to audio track updates
|
||||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_event: any, data: any) => {
|
||||
console.log('🔄 [HLS] Audio tracks updated:', data)
|
||||
const tracks = getHlsAudioTracks(hls)
|
||||
if (tracks.length > 0) {
|
||||
console.log(`✅ [HLS] Found ${tracks.length} audio tracks after update:`, tracks)
|
||||
setAvailableAudioTracks(tracks)
|
||||
onAudioTracksLoaded?.(tracks)
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
||||
console.error('❌ [HLS] Error:', data)
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('❌ [HLS] Fatal network error, trying to recover...')
|
||||
hls.startLoad()
|
||||
break
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('❌ [HLS] Fatal media error, trying to recover...')
|
||||
hls.recoverMediaError()
|
||||
break
|
||||
default:
|
||||
console.error('❌ [HLS] Fatal error, cannot recover')
|
||||
handleError()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Store hls instance for cleanup
|
||||
;(video as any).__hlsInstance = hls
|
||||
|
||||
// Setup cleanup function
|
||||
cleanupFn = () => {
|
||||
console.log('🧹 [HLS] Cleaning up HLS instance...')
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ [HLS] Failed to load or initialize hls.js:', err)
|
||||
let error: Error
|
||||
if (err instanceof Error && isCORSError(err)) {
|
||||
const corsMessage = getCORSErrorMessage(src)
|
||||
console.error(corsMessage)
|
||||
error = new Error('Failed to load HLS stream. This might be a CORS issue. Check console for details.')
|
||||
} 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) {
|
||||
video.play().catch((err) => {
|
||||
console.warn('⚠️ [Video] Autoplay prevented:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
console.log(`✅ [Video] Audio track changed to: ${settings.audioTrack.name}`)
|
||||
}
|
||||
}, [settings.audioTrack, availableAudioTracks, videoRef])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user