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
+156
View File
@@ -0,0 +1,156 @@
/**
* CORS helper utilities for video loading
*/
export interface CORSCheckResult {
supported: boolean
error?: string
needsProxy: boolean
}
/**
* Check if a video URL supports CORS and Range Requests
*/
export const checkVideoCORS = async (url: string): Promise<CORSCheckResult> => {
try {
// Make a HEAD request to check headers
const response = await fetch(url, {
method: 'HEAD',
mode: 'cors',
})
const corsHeader = response.headers.get('Access-Control-Allow-Origin')
const rangeHeader = response.headers.get('Accept-Ranges')
if (!corsHeader && !response.ok) {
return {
supported: false,
error: 'CORS not enabled on video server',
needsProxy: true,
}
}
if (!rangeHeader || rangeHeader === 'none') {
console.warn('⚠️ [CORS] Server does not support Range Requests. Seeking may not work properly.')
}
return {
supported: true,
needsProxy: false,
}
} catch (error) {
// CORS error or network error
if (error instanceof TypeError && error.message.includes('CORS')) {
return {
supported: false,
error: 'CORS blocked by browser',
needsProxy: true,
}
}
return {
supported: false,
error: error instanceof Error ? error.message : 'Unknown error',
needsProxy: true,
}
}
}
/**
* Check if URL is from the same origin
*/
export const isSameOrigin = (url: string): boolean => {
try {
const urlObj = new URL(url, window.location.href)
return urlObj.origin === window.location.origin
} catch {
return false
}
}
/**
* Check if URL is a blob or data URL
*/
export const isBlobOrDataURL = (url: string): boolean => {
return url.startsWith('blob:') || url.startsWith('data:')
}
/**
* Validate video URL and provide helpful error messages
*/
export const validateVideoURL = (url: string): { valid: boolean; error?: string; warning?: string } => {
if (!url || url.trim() === '') {
return {
valid: false,
error: 'Video URL is empty',
}
}
// Check if it's a valid URL
try {
new URL(url, window.location.href)
} catch {
return {
valid: false,
error: 'Invalid video URL format',
}
}
// Same origin - no CORS issues
if (isSameOrigin(url)) {
return { valid: true }
}
// Blob or data URL - no CORS issues
if (isBlobOrDataURL(url)) {
return { valid: true }
}
// External URL - potential CORS issues
return {
valid: true,
warning: 'External video URL detected. Ensure server has proper CORS headers.',
}
}
/**
* Get CORS error message with helpful suggestions
*/
export const getCORSErrorMessage = (url: string): string => {
const isExternal = !isSameOrigin(url) && !isBlobOrDataURL(url)
if (!isExternal) {
return 'Failed to load video. Please check the URL.'
}
return `
❌ CORS Error: Unable to load video from external source.
The video server at "${new URL(url).origin}" does not allow cross-origin requests.
To fix this issue:
1. Add CORS headers to your video server:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Allow-Headers: Range
2. Use a proxy server to bypass CORS restrictions
3. Host the video on the same domain as your application
4. Use a CDN that supports CORS (e.g., Cloudflare, AWS CloudFront)
`.trim()
}
/**
* Check if error is CORS-related
*/
export const isCORSError = (error: Error): boolean => {
const message = error.message.toLowerCase()
return (
message.includes('cors') ||
message.includes('cross-origin') ||
message.includes('blocked by cors policy') ||
message.includes('no \'access-control-allow-origin\'')
)
}
+139
View File
@@ -0,0 +1,139 @@
/**
* HLS.js dynamic loader with CDN fallback
* Handles loading hls.js from npm or CDN
*/
import type { AudioTrack } from '../types'
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
/**
* Load hls.js from CDN as fallback
*/
const loadHlsFromCDN = (): Promise<any> => {
return new Promise((resolve, reject) => {
// Check if already loaded globally
if (typeof (window as any).Hls !== 'undefined') {
resolve((window as any).Hls)
return
}
const script = document.createElement('script')
script.src = HLS_CDN_URL
script.async = true
script.onload = () => {
if (typeof (window as any).Hls !== 'undefined') {
console.log('✅ [HLS Loader] Loaded hls.js from CDN')
resolve((window as any).Hls)
} else {
reject(new Error('HLS.js CDN loaded but Hls global not found'))
}
}
script.onerror = () => {
reject(new Error('Failed to load hls.js from CDN'))
}
document.head.appendChild(script)
})
}
/**
* Load hls.js with npm fallback to CDN
*/
export const loadHls = async (): Promise<any> => {
try {
// Try loading from npm package first
console.log('🔄 [HLS Loader] Attempting to load hls.js from npm package...')
const hlsModule = await import('hls.js')
console.log('✅ [HLS Loader] Loaded hls.js from npm package')
return hlsModule.default
} catch (npmError) {
console.warn('⚠️ [HLS Loader] Failed to load hls.js from npm, trying CDN fallback...', npmError)
try {
// Fallback to CDN
const Hls = await loadHlsFromCDN()
return Hls
} catch (cdnError) {
console.error('❌ [HLS Loader] Failed to load hls.js from both npm and CDN')
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
}
}
}
/**
* Check if HLS.js is supported in current browser
*/
export const isHlsSupported = (Hls: any): boolean => {
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
}
/**
* Check if browser has native HLS support (Safari)
*/
export const hasNativeHlsSupport = (): boolean => {
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
}
/**
* Extract audio tracks from HLS instance
*/
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
try {
if (!hls) {
console.warn('⚠️ [HLS Loader] HLS instance is null or undefined')
return []
}
// Check if audioTracks property exists
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
console.warn('⚠️ [HLS Loader] audioTracks not available or not an array:', hls.audioTracks)
return []
}
console.log('🔍 [HLS Loader] Raw audio tracks from HLS:', hls.audioTracks)
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
const audioTrack = {
name: track.name || track.label || `Audio ${index + 1}`,
language: track.lang || track.language || 'unknown',
url: track.url || '',
groupId: track.groupId || 'audio',
default: track.default || false,
autoselect: track.autoselect || false,
}
console.log(`🎵 [HLS Loader] Parsed audio track ${index}:`, audioTrack)
return audioTrack
})
return audioTracks
} catch (error) {
console.error('❌ [HLS Loader] Error extracting audio tracks:', error)
return []
}
}
/**
* Set active audio track in HLS instance
*/
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
try {
if (!hls || !hls.audioTracks) {
console.warn('⚠️ [HLS Loader] HLS instance or audioTracks not available')
return
}
if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) {
console.warn('⚠️ [HLS Loader] Invalid audio track index:', audioTrackIndex)
return
}
hls.audioTrack = audioTrackIndex
console.log(`✅ [HLS Loader] Audio track set to index ${audioTrackIndex}`)
} catch (error) {
console.error('❌ [HLS Loader] Error setting audio track:', error)
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { AudioTrack } from '../types'
/**
* Parses M3U8 manifest to extract audio tracks
*/
export const parseM3U8AudioTracks = (manifestContent: string): AudioTrack[] => {
const audioTracks: AudioTrack[] = []
const lines = manifestContent.split('\n')
for (const line of lines) {
if (line.startsWith('#EXT-X-MEDIA:TYPE=AUDIO')) {
const track = parseAudioMediaTag(line)
if (track) {
audioTracks.push(track)
}
}
}
return audioTracks
}
/**
* Parses a single #EXT-X-MEDIA:TYPE=AUDIO line
* Example: #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=NO,URI="audio_en.m3u8"
*/
const parseAudioMediaTag = (line: string): AudioTrack | null => {
try {
const attributes: Record<string, string> = {}
// Extract all key-value pairs
const regex = /(\w+(?:-\w+)*)=("(?:[^"\\]|\\.)*"|[^,]+)/g
let match
while ((match = regex.exec(line)) !== null) {
const key = match[1]
let value = match[2]
// Remove quotes if present
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1)
}
attributes[key] = value
}
// Only process if it's an AUDIO type
if (attributes['TYPE'] !== 'AUDIO') {
return null
}
// Extract required fields
const name = attributes['NAME']
const language = attributes['LANGUAGE'] || attributes['LANG'] || 'unknown'
const uri = attributes['URI']
const groupId = attributes['GROUP-ID'] || 'audio'
const defaultTrack = attributes['DEFAULT'] === 'YES'
const autoselect = attributes['AUTOSELECT'] === 'YES'
if (!name || !uri) {
console.warn('⚠️ [M3U8 Parser] Audio track missing NAME or URI:', line)
return null
}
return {
name,
language,
url: uri,
groupId,
default: defaultTrack,
autoselect,
}
} catch (error) {
console.error('❌ [M3U8 Parser] Error parsing audio track:', line, error)
return null
}
}
/**
* Fetches and parses M3U8 manifest from URL
*/
export const fetchAndParseM3U8 = async (url: string): Promise<AudioTrack[]> => {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch M3U8: ${response.statusText}`)
}
const manifestContent = await response.text()
return parseM3U8AudioTracks(manifestContent)
} catch (error) {
console.error('❌ [M3U8 Parser] Error fetching M3U8:', error)
return []
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Polyfills for older browser support
* Ensures compatibility with browsers that don't support modern APIs
*/
/**
* Polyfill for Fullscreen API
* Handles vendor prefixes for older browsers
*/
export const setupFullscreenPolyfill = () => {
if (!document.exitFullscreen) {
// @ts-ignore - Legacy API
document.exitFullscreen = document.webkitExitFullscreen ||
// @ts-ignore
document.mozCancelFullScreen ||
// @ts-ignore
document.msExitFullscreen
}
if (!Element.prototype.requestFullscreen) {
// @ts-ignore - Legacy API
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullscreen ||
// @ts-ignore
Element.prototype.mozRequestFullScreen ||
// @ts-ignore
Element.prototype.msRequestFullscreen
}
// Fullscreen change event polyfill
if (!('onfullscreenchange' in document)) {
const events = ['webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
events.forEach(event => {
document.addEventListener(event, () => {
const fullscreenChangeEvent = new Event('fullscreenchange')
document.dispatchEvent(fullscreenChangeEvent)
})
})
}
// fullscreenElement polyfill
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
Object.defineProperty(document, 'fullscreenElement', {
get: function() {
// @ts-ignore
return this.webkitFullscreenElement ||
// @ts-ignore
this.mozFullScreenElement ||
// @ts-ignore
this.msFullscreenElement
}
})
}
}
/**
* Polyfill for Picture-in-Picture API
* Checks if PIP is supported
*/
export const setupPIPPolyfill = () => {
// Check if PIP is supported
if (!('pictureInPictureEnabled' in document)) {
Object.defineProperty(document, 'pictureInPictureEnabled', {
get: function() {
// PIP not supported in this browser
return false
}
})
}
}
/**
* Promise polyfill check
* Modern browsers should have Promise, but we check anyway
*/
export const checkPromiseSupport = (): boolean => {
return typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1
}
/**
* Fetch API polyfill check
*/
export const checkFetchSupport = (): boolean => {
return typeof fetch !== 'undefined'
}
/**
* Initialize all polyfills
* Call this once when the app loads
*/
export const initializePolyfills = () => {
try {
setupFullscreenPolyfill()
setupPIPPolyfill()
// Check critical API support
if (!checkPromiseSupport()) {
console.warn('[VideoPlayer] Promise not supported. Please add Promise polyfill.')
}
if (!checkFetchSupport()) {
console.warn('[VideoPlayer] Fetch API not supported. Subtitle loading may fail.')
}
// Check for MediaSource API (required for HLS.js)
if (typeof MediaSource === 'undefined') {
console.warn('[VideoPlayer] MediaSource API not supported. HLS streaming will not work.')
}
console.log('✅ [VideoPlayer] Polyfills initialized successfully')
} catch (error) {
console.error('[VideoPlayer] Error initializing polyfills:', error)
}
}
/**
* Feature detection utilities
*/
export const features = {
/**
* Check if browser supports HLS natively
*/
hasNativeHLS: (): boolean => {
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
},
/**
* Check if browser supports MSE (required for HLS.js)
*/
hasMSE: (): boolean => {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"')
},
/**
* Check if Picture-in-Picture is truly supported
*/
hasPIP: (): boolean => {
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
},
/**
* Check if Fullscreen API is supported
*/
hasFullscreen: (): boolean => {
return !!(
document.fullscreenEnabled ||
// @ts-ignore
document.webkitFullscreenEnabled ||
// @ts-ignore
document.mozFullScreenEnabled ||
// @ts-ignore
document.msFullscreenEnabled
)
},
/**
* Check if touch events are supported (mobile device)
*/
hasTouch: (): boolean => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
},
/**
* Detect iOS Safari
*/
isIOSSafari: (): boolean => {
const ua = navigator.userAgent
const iOS = /iPad|iPhone|iPod/.test(ua)
const webkit = /WebKit/.test(ua)
return iOS && webkit && !/CriOS|FxiOS|OPiOS|mercury/.test(ua)
},
/**
* Check if programmatic volume control is supported (not on iOS)
*/
hasVolumeControl: (): boolean => {
return !features.isIOSSafari()
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Parse SRT subtitle format to WebVTT
*/
export const parseSRT = (srtContent: string): string => {
const lines = srtContent.trim().split('\n')
let vttContent = 'WEBVTT\n\n'
let i = 0
while (i < lines.length) {
// Skip subtitle number
if (/^\d+$/.test(lines[i].trim())) {
i++
}
// Parse timestamp line
if (lines[i] && lines[i].includes('-->')) {
const timeLine = lines[i].replace(/,/g, '.') // SRT uses comma, VTT uses dot
vttContent += timeLine + '\n'
i++
// Add subtitle text
while (i < lines.length && lines[i].trim() !== '') {
vttContent += lines[i] + '\n'
i++
}
vttContent += '\n'
}
i++
}
return vttContent
}
/**
* Create a blob URL from subtitle content
*/
export const createSubtitleBlobURL = (content: string, format: 'vtt' | 'srt'): string => {
const vttContent = format === 'srt' ? parseSRT(content) : content
const blob = new Blob([vttContent], { type: 'text/vtt' })
return URL.createObjectURL(blob)
}
/**
* Fetch and parse subtitle file
*/
export const fetchSubtitle = async (url: string): Promise<string> => {
try {
const response = await fetch(url)
const content = await response.text()
// Detect format
if (url.endsWith('.srt')) {
return parseSRT(content)
}
return content
} catch (error) {
console.error('Failed to fetch subtitle:', error)
throw error
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Format seconds to MM:SS or HH:MM:SS
*/
export const formatTime = (seconds: number): string => {
if (isNaN(seconds) || !isFinite(seconds)) {
return '0:00'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
/**
* Parse time string (MM:SS or HH:MM:SS) to seconds
*/
export const parseTime = (timeString: string): number => {
const parts = timeString.split(':').map(Number)
if (parts.length === 2) {
// MM:SS
return parts[0] * 60 + parts[1]
} else if (parts.length === 3) {
// HH:MM:SS
return parts[0] * 3600 + parts[1] * 60 + parts[2]
}
return 0
}