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:
hibna
2025-10-29 07:49:06 +03:00
parent d68df70124
commit b57b24d051
47 changed files with 4414 additions and 0 deletions
+406
View File
@@ -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>
)
}