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,73 @@
|
||||
.controls-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--player-z-controls);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
transition: opacity var(--player-transition-normal) ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.controls-layer.hidden.playing {
|
||||
opacity: 0;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* Allow clicks to pass through to video when controls are hidden */
|
||||
.controls-layer.hidden.playing .controls-bar {
|
||||
transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls-layer.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
background: linear-gradient(to top, var(--player-bg-controls) 0%, transparent 100%);
|
||||
padding: var(--player-spacing-xl) var(--player-spacing-lg) var(--player-spacing-lg);
|
||||
transition: transform var(--player-transition-normal) ease;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: var(--player-spacing-md);
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--player-spacing-sm);
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 640px) {
|
||||
.controls-bar {
|
||||
padding: var(--player-spacing-lg) var(--player-spacing-md) var(--player-spacing-md);
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
gap: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
gap: var(--player-spacing-xs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { PlayPauseButton } from './controls/PlayPauseButton'
|
||||
import { ProgressBar } from './controls/ProgressBar'
|
||||
import { VolumeControl } from './controls/VolumeControl'
|
||||
import { TimeDisplay } from './controls/TimeDisplay'
|
||||
import { FullscreenButton } from './controls/FullscreenButton'
|
||||
import { PIPButton } from './controls/PIPButton'
|
||||
import { SettingsButton } from './controls/SettingsButton'
|
||||
import { LoadingSpinner } from './overlays/LoadingSpinner'
|
||||
import { CenterPlayButton } from './controls/CenterPlayButton'
|
||||
import { SettingsMenu } from './menus/SettingsMenu'
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
|
||||
import { useTouchGestures } from '../hooks/useTouchGestures'
|
||||
import type { SubtitleTrack, AudioTrack } from '../types'
|
||||
import './ControlsLayer.css'
|
||||
|
||||
interface ControlsLayerProps {
|
||||
keyboardShortcuts?: boolean
|
||||
pictureInPicture?: boolean
|
||||
subtitles?: SubtitleTrack[]
|
||||
audioTracks?: AudioTrack[]
|
||||
}
|
||||
|
||||
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
keyboardShortcuts = true,
|
||||
pictureInPicture = true,
|
||||
subtitles = [],
|
||||
audioTracks = [],
|
||||
}) => {
|
||||
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } = usePlayerContext()
|
||||
const [mouseMoving, setMouseMoving] = useState(false)
|
||||
const hideTimeoutRef = useRef<number>()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const lastClickTimeRef = useRef<number>(0)
|
||||
|
||||
// Auto-hide controls after inactivity
|
||||
useEffect(() => {
|
||||
if (videoState.playing) {
|
||||
// Show controls on mouse movement
|
||||
if (mouseMoving) {
|
||||
showControls()
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (hideTimeoutRef.current) {
|
||||
window.clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Hide controls after inactivity (3 seconds in all modes)
|
||||
if (mouseMoving) {
|
||||
const hideDelay = 3000
|
||||
hideTimeoutRef.current = window.setTimeout(() => {
|
||||
hideControls()
|
||||
setMouseMoving(false)
|
||||
}, hideDelay)
|
||||
}
|
||||
} else {
|
||||
// Always show controls when paused
|
||||
showControls()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hideTimeoutRef.current) {
|
||||
window.clearTimeout(hideTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [mouseMoving, videoState.playing, videoState.fullscreen, showControls, hideControls])
|
||||
|
||||
// Handle mouse movement
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!mouseMoving) {
|
||||
setMouseMoving(true)
|
||||
}
|
||||
}, [mouseMoving])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Only hide controls on mouse leave when in fullscreen mode
|
||||
// When player is small, controls should stay visible
|
||||
setMouseMoving(false)
|
||||
if (videoState.playing && videoState.fullscreen) {
|
||||
hideControls()
|
||||
}
|
||||
}, [videoState.playing, videoState.fullscreen, hideControls])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts(keyboardShortcuts)
|
||||
|
||||
// Touch gestures
|
||||
useTouchGestures(containerRef)
|
||||
|
||||
// Handle click for play/pause and double-click for fullscreen
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Get the actual element that was clicked
|
||||
const target = e.target as HTMLElement
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
|
||||
// Allow clicks on:
|
||||
// 1. The controls layer itself (when controls are hidden, pointer-events: none makes it work)
|
||||
// 2. The center play overlay
|
||||
// Don't handle clicks on control buttons or other interactive elements
|
||||
const isClickableArea =
|
||||
target === currentTarget ||
|
||||
target.classList.contains('center-play-overlay') ||
|
||||
target.classList.contains('controls-layer')
|
||||
|
||||
if (!isClickableArea) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastClick = now - lastClickTimeRef.current
|
||||
|
||||
if (timeSinceLastClick < 300) {
|
||||
// Double click - toggle fullscreen
|
||||
e.preventDefault() // Prevent text selection on double click
|
||||
toggleFullscreen()
|
||||
lastClickTimeRef.current = 0
|
||||
} else {
|
||||
// Single click - toggle play/pause (with delay to detect double click)
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastClickTimeRef.current >= 300) {
|
||||
togglePlay()
|
||||
}
|
||||
}, 300)
|
||||
lastClickTimeRef.current = now
|
||||
}
|
||||
},
|
||||
[togglePlay, toggleFullscreen]
|
||||
)
|
||||
|
||||
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
||||
videoState.playing ? 'playing' : 'paused'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={controlsClassName}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Loading spinner */}
|
||||
{videoState.loading && <LoadingSpinner />}
|
||||
|
||||
{/* Center play button (only when paused) */}
|
||||
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
|
||||
|
||||
{/* Bottom controls bar */}
|
||||
<div className="controls-bar">
|
||||
{/* Progress bar (full width on top) */}
|
||||
<div className="progress-container">
|
||||
<ProgressBar />
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="controls-row">
|
||||
<div className="controls-left">
|
||||
<PlayPauseButton />
|
||||
<VolumeControl />
|
||||
<TimeDisplay />
|
||||
</div>
|
||||
|
||||
<div className="controls-right">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SettingsButton />
|
||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} />
|
||||
</div>
|
||||
{pictureInPicture && <PIPButton />}
|
||||
<FullscreenButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--player-z-video);
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide native controls */
|
||||
.video-element::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-element::-webkit-media-controls-enclosure {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-element::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: var(--player-bg);
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.video-player *,
|
||||
.video-player *::before,
|
||||
.video-player *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.video-player:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.video-player:-webkit-full-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.video-player:-moz-full-screen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.video-player:-ms-fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Aspect ratio container */
|
||||
.video-player::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 56.25%; /* 16:9 aspect ratio */
|
||||
}
|
||||
|
||||
.video-player > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Remove default video controls */
|
||||
.video-player video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-player video::-webkit-media-controls-enclosure {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { PlayerProvider } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
import type { VideoPlayerProps, AudioTrack } from '../types'
|
||||
import { initializePolyfills } from '../utils/polyfills'
|
||||
import '../styles/variables.css'
|
||||
import './VideoPlayer.css'
|
||||
|
||||
// Initialize polyfills once
|
||||
let polyfillsInitialized = false
|
||||
if (!polyfillsInitialized) {
|
||||
initializePolyfills()
|
||||
polyfillsInitialized = true
|
||||
}
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
src,
|
||||
poster,
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
muted = false,
|
||||
controls = true,
|
||||
subtitles = [],
|
||||
theme,
|
||||
keyboardShortcuts = true,
|
||||
pictureInPicture = true,
|
||||
className = '',
|
||||
style,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onTimeUpdate,
|
||||
onVolumeChange,
|
||||
onError,
|
||||
onLoadedMetadata,
|
||||
onSeeking,
|
||||
onSeeked,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||
|
||||
// Apply theme CSS variables
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
const root = document.documentElement
|
||||
if (theme.primaryColor) root.style.setProperty('--player-primary', theme.primaryColor)
|
||||
if (theme.accentColor) root.style.setProperty('--player-primary-hover', theme.accentColor)
|
||||
if (theme.backgroundColor) root.style.setProperty('--player-bg', theme.backgroundColor)
|
||||
if (theme.textColor) root.style.setProperty('--player-text', theme.textColor)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
||||
setAudioTracks(tracks)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlayerProvider initialMuted={muted}>
|
||||
<div className={`video-player ${className}`} style={style}>
|
||||
<VideoElement
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoplay={autoplay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
subtitles={subtitles}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onError={onError}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onAudioTracksLoaded={handleAudioTracksLoaded}
|
||||
/>
|
||||
{controls && (
|
||||
<ControlsLayer
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
pictureInPicture={pictureInPicture}
|
||||
subtitles={subtitles}
|
||||
audioTracks={audioTracks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PlayerProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.center-play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.center-play-button {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--player-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--player-transition-normal) ease;
|
||||
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.center-play-button:hover {
|
||||
background-color: var(--player-primary-hover);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 30px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
.center-play-button:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.center-play-button svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-left: 4px; /* Optical adjustment for play icon */
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.center-play-button {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.center-play-button svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { PlayIcon } from '../../icons'
|
||||
import './CenterPlayButton.css'
|
||||
|
||||
export const CenterPlayButton: React.FC = () => {
|
||||
const { play } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<div className="center-play-overlay">
|
||||
<button
|
||||
className="center-play-button"
|
||||
onClick={play}
|
||||
aria-label="Play"
|
||||
title="Play"
|
||||
>
|
||||
<PlayIcon size={64} color="var(--player-text)" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.control-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--player-text);
|
||||
cursor: pointer;
|
||||
padding: var(--player-spacing-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--player-radius-sm);
|
||||
transition: all var(--player-transition-fast) ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.control-button:focus-visible {
|
||||
outline: 2px solid var(--player-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-button:disabled:hover {
|
||||
background-color: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Icon sizing */
|
||||
.control-button svg {
|
||||
width: var(--player-icon-md);
|
||||
height: var(--player-icon-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-button {
|
||||
padding: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.control-button svg {
|
||||
width: var(--player-icon-sm);
|
||||
height: var(--player-icon-sm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { FullscreenIcon, FullscreenExitIcon } from '../../icons'
|
||||
import './ControlButton.css'
|
||||
|
||||
export const FullscreenButton: React.FC = () => {
|
||||
const { videoState, toggleFullscreen } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button fullscreen-button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={videoState.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
title={videoState.fullscreen ? 'Exit fullscreen (F)' : 'Enter fullscreen (F)'}
|
||||
>
|
||||
{videoState.fullscreen ? (
|
||||
<FullscreenExitIcon size={24} color="var(--player-text)" />
|
||||
) : (
|
||||
<FullscreenIcon size={24} color="var(--player-text)" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { PIPIcon } from '../../icons'
|
||||
import './ControlButton.css'
|
||||
|
||||
export const PIPButton: React.FC = () => {
|
||||
const { videoState, togglePictureInPicture } = usePlayerContext()
|
||||
|
||||
// Check if PIP is supported
|
||||
const isPIPSupported = 'pictureInPictureEnabled' in document
|
||||
|
||||
if (!isPIPSupported) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button pip-button"
|
||||
onClick={togglePictureInPicture}
|
||||
aria-label={videoState.pictureInPicture ? 'Exit picture-in-picture' : 'Enter picture-in-picture'}
|
||||
title="Picture-in-picture (P)"
|
||||
>
|
||||
<PIPIcon size={24} color="var(--player-text)" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { PlayIcon, PauseIcon } from '../../icons'
|
||||
import './ControlButton.css'
|
||||
|
||||
export const PlayPauseButton: React.FC = () => {
|
||||
const { videoState, togglePlay } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button play-pause-button"
|
||||
onClick={togglePlay}
|
||||
aria-label={videoState.playing ? 'Pause' : 'Play'}
|
||||
title={videoState.playing ? 'Pause (Space)' : 'Play (Space)'}
|
||||
>
|
||||
{videoState.playing ? (
|
||||
<PauseIcon size={24} color="var(--player-text)" />
|
||||
) : (
|
||||
<PlayIcon size={24} color="var(--player-text)" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--player-progress-bg);
|
||||
border-radius: var(--player-radius-full);
|
||||
overflow: hidden;
|
||||
transition: height var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-track,
|
||||
.progress-bar.seeking .progress-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress-buffered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: var(--player-buffered);
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.progress-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: var(--player-primary);
|
||||
transition: width 0.1s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.progress-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--player-primary);
|
||||
border-radius: 50%;
|
||||
transform: scale(0);
|
||||
transition: transform var(--player-transition-fast) ease;
|
||||
margin-right: -6px;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-handle,
|
||||
.progress-bar.seeking .progress-handle {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.progress-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--player-bg-menu);
|
||||
color: var(--player-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--player-radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--player-shadow-md);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.progress-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--player-bg-menu);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.progress-bar {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-track,
|
||||
.progress-bar.seeking .progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.progress-handle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
/* Always show handle on mobile for easier touch interaction */
|
||||
.progress-handle {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import './ProgressBar.css'
|
||||
|
||||
export const ProgressBar: React.FC = () => {
|
||||
const { videoState, seek } = usePlayerContext()
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [seeking, setSeeking] = useState(false)
|
||||
const [hoverTime, setHoverTime] = useState<number | null>(null)
|
||||
const [hoverPosition, setHoverPosition] = useState<number>(0)
|
||||
|
||||
const getProgressFromPosition = useCallback(
|
||||
(clientX: number): number => {
|
||||
if (!progressRef.current) return 0
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const position = (clientX - rect.left) / rect.width
|
||||
return Math.max(0, Math.min(1, position))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setSeeking(true)
|
||||
const progress = getProgressFromPosition(e.clientX)
|
||||
seek(progress * videoState.duration)
|
||||
},
|
||||
[getProgressFromPosition, seek, videoState.duration]
|
||||
)
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const progress = getProgressFromPosition(e.clientX)
|
||||
const time = progress * videoState.duration
|
||||
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const position = e.clientX - rect.left
|
||||
|
||||
setHoverTime(time)
|
||||
setHoverPosition(position)
|
||||
|
||||
if (seeking) {
|
||||
seek(time)
|
||||
}
|
||||
},
|
||||
[getProgressFromPosition, videoState.duration, seeking, seek]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setSeeking(false)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverTime(null)
|
||||
setSeeking(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (seeking) {
|
||||
const handleGlobalMouseUp = () => setSeeking(false)
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp)
|
||||
return () => window.removeEventListener('mouseup', handleGlobalMouseUp)
|
||||
}
|
||||
}, [seeking])
|
||||
|
||||
const progress = videoState.duration > 0 ? (videoState.currentTime / videoState.duration) * 100 : 0
|
||||
const buffered = videoState.duration > 0 ? (videoState.buffered / videoState.duration) * 100 : 0
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (isNaN(seconds) || !isFinite(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`progress-bar ${seeking ? 'seeking' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role="slider"
|
||||
aria-label="Video progress"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={videoState.duration}
|
||||
aria-valuenow={videoState.currentTime}
|
||||
aria-valuetext={formatTime(videoState.currentTime)}
|
||||
>
|
||||
{/* Background track */}
|
||||
<div className="progress-track">
|
||||
{/* Buffered progress */}
|
||||
<div className="progress-buffered" style={{ width: `${buffered}%` }} />
|
||||
|
||||
{/* Played progress */}
|
||||
<div className="progress-played" style={{ width: `${progress}%` }}>
|
||||
<div className="progress-handle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover time tooltip */}
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className="progress-tooltip"
|
||||
style={{
|
||||
left: `${hoverPosition}px`,
|
||||
}}
|
||||
>
|
||||
{formatTime(hoverTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { SettingsIcon } from '../../icons'
|
||||
import './ControlButton.css'
|
||||
|
||||
export const SettingsButton: React.FC = () => {
|
||||
const { toggleSettings } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button settings-button"
|
||||
onClick={toggleSettings}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon size={24} color="var(--player-text)" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--player-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
.time-duration {
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { formatTime } from '../../utils/time'
|
||||
import './TimeDisplay.css'
|
||||
|
||||
export const TimeDisplay: React.FC = () => {
|
||||
const { videoState } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<div className="time-display">
|
||||
<span className="time-current">{formatTime(videoState.currentTime)}</span>
|
||||
<span className="time-separator">/</span>
|
||||
<span className="time-duration">{formatTime(videoState.duration)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.volume-slider-container {
|
||||
position: relative;
|
||||
width: 0;
|
||||
height: 6px;
|
||||
background-color: var(--player-progress-bg);
|
||||
border-radius: var(--player-radius-full);
|
||||
overflow: visible;
|
||||
transition: width var(--player-transition-normal) ease, opacity var(--player-transition-normal) ease;
|
||||
opacity: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.volume-slider-container.visible {
|
||||
width: 100px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(-50%);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--player-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: var(--player-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.volume-slider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.volume-slider:focus-visible::-webkit-slider-thumb {
|
||||
outline: 2px solid var(--player-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.volume-slider:focus-visible::-moz-range-thumb {
|
||||
outline: 2px solid var(--player-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.volume-slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--player-primary) 0%, var(--player-primary-hover) 100%);
|
||||
pointer-events: none;
|
||||
transition: width 0.1s ease;
|
||||
z-index: 1;
|
||||
border-radius: var(--player-radius-full);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* Mobile: Show slider vertically */
|
||||
@media (max-width: 640px) {
|
||||
.volume-slider-container.visible {
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { VolumeUpIcon, VolumeDownIcon, VolumeMuteIcon } from '../../icons'
|
||||
import './VolumeControl.css'
|
||||
|
||||
export const VolumeControl: React.FC = () => {
|
||||
const { videoState, setVolume, toggleMute } = usePlayerContext()
|
||||
const [showSlider, setShowSlider] = useState(false)
|
||||
const timeoutRef = useRef<number>()
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
setShowSlider(true)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setShowSlider(false)
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const volume = parseFloat(e.target.value)
|
||||
setVolume(volume)
|
||||
},
|
||||
[setVolume]
|
||||
)
|
||||
|
||||
const VolumeIcon = videoState.muted ? VolumeMuteIcon : videoState.volume > 0.5 ? VolumeUpIcon : VolumeDownIcon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="volume-control"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className="control-button volume-button"
|
||||
onClick={toggleMute}
|
||||
aria-label={videoState.muted ? 'Unmute' : 'Mute'}
|
||||
title={videoState.muted ? 'Unmute (M)' : 'Mute (M)'}
|
||||
>
|
||||
<VolumeIcon size={24} color="var(--player-text)" />
|
||||
</button>
|
||||
|
||||
<div className={`volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={videoState.muted ? 0 : videoState.volume}
|
||||
onChange={handleSliderChange}
|
||||
className="volume-slider"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<div
|
||||
className="volume-slider-fill"
|
||||
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
.settings-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
background-color: var(--player-bg-menu);
|
||||
border-radius: var(--player-radius-lg);
|
||||
box-shadow: var(--player-shadow-lg);
|
||||
min-width: 300px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
z-index: var(--player-z-menu);
|
||||
animation: slideUp var(--player-transition-normal) ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.settings-menu-header {
|
||||
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||
border-bottom: 1px solid var(--player-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-menu-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--player-text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-back-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--player-text);
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--player-radius-sm);
|
||||
transition: background-color var(--player-transition-fast) ease;
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.settings-back-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Main Options (Two-level menu) */
|
||||
.settings-main-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-main-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-md);
|
||||
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--player-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--player-transition-fast) ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-main-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-main-option-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border-radius: var(--player-radius-md);
|
||||
}
|
||||
|
||||
.settings-main-option-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-main-option-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--player-text);
|
||||
}
|
||||
|
||||
.settings-main-option-value {
|
||||
font-size: 13px;
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
.settings-main-option-arrow {
|
||||
font-size: 24px;
|
||||
color: var(--player-text-secondary);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Submenu Options */
|
||||
.settings-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--player-text);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--player-transition-fast) ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.settings-option.active {
|
||||
color: var(--player-primary);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.settings-option span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Empty state message */
|
||||
.settings-empty-state {
|
||||
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||
text-align: center;
|
||||
color: var(--player-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.settings-options::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-menu {
|
||||
min-width: 280px;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.settings-main-option {
|
||||
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||
}
|
||||
|
||||
.settings-main-option-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||
import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon } from '../../icons'
|
||||
import type { AudioTrack } from '../../types'
|
||||
import './SettingsMenu.css'
|
||||
|
||||
interface SettingsMenuProps {
|
||||
subtitles?: Array<{ src: string; lang: string; label: string }>
|
||||
audioTracks?: AudioTrack[]
|
||||
}
|
||||
|
||||
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio'
|
||||
|
||||
export const SettingsMenu: React.FC<SettingsMenuProps> = ({ subtitles = [], audioTracks = [] }) => {
|
||||
const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||
|
||||
const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!uiState.settingsOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
toggleSettings()
|
||||
setCurrentView('main')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [uiState.settingsOpen, toggleSettings])
|
||||
|
||||
// Reset to main view when menu closes
|
||||
useEffect(() => {
|
||||
if (!uiState.settingsOpen) {
|
||||
setCurrentView('main')
|
||||
}
|
||||
}, [uiState.settingsOpen])
|
||||
|
||||
const goBack = () => {
|
||||
setCurrentView('main')
|
||||
}
|
||||
|
||||
if (!uiState.settingsOpen) return null
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="settings-menu">
|
||||
{/* Main Menu */}
|
||||
{currentView === 'main' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<h3>Ayarlar</h3>
|
||||
</div>
|
||||
<div className="settings-main-options">
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<SpeedIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Hız</span>
|
||||
<span className="settings-main-option-value">
|
||||
{videoState.playbackRate === 1 ? 'Normal' : `${videoState.playbackRate}x`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<SubtitlesIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Altyazı</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.subtitle ? settings.subtitle.label : 'Kapalı'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
|
||||
{audioTracks.length > 0 && (
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('audio')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<AudioIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">Ses</span>
|
||||
<span className="settings-main-option-value">
|
||||
{settings.audioTrack ? settings.audioTrack.name : 'Varsayılan'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Speed Submenu */}
|
||||
{currentView === 'speed' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Oynatma Hızı</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
{playbackRates.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPlaybackRate(rate)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>{rate === 1 ? 'Normal' : `${rate}x`}</span>
|
||||
{videoState.playbackRate === rate && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Subtitles Submenu */}
|
||||
{currentView === 'subtitles' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Altyazı</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<button
|
||||
className={`settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSubtitle(null)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>Kapalı</span>
|
||||
{!settings.subtitle && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
{subtitles.length > 0 ? (
|
||||
subtitles.map((subtitle) => (
|
||||
<button
|
||||
key={subtitle.lang}
|
||||
className={`settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSubtitle(subtitle)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>{subtitle.label}</span>
|
||||
{settings.subtitle?.lang === subtitle.lang && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="settings-empty-state">
|
||||
<span>Altyazı mevcut değil</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Audio Submenu */}
|
||||
{currentView === 'audio' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>Ses</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
{audioTracks.map((track) => (
|
||||
<button
|
||||
key={track.language}
|
||||
className={`settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAudioTrack(track)
|
||||
setTimeout(() => goBack(), 150)
|
||||
}}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
{settings.audioTrack?.language === track.language && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.loading-spinner-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: var(--player-z-loading);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: fadeIn var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { LoadingIcon } from '../../icons'
|
||||
import './LoadingSpinner.css'
|
||||
|
||||
export const LoadingSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="loading-spinner-overlay">
|
||||
<div className="loading-spinner">
|
||||
<LoadingIcon size={48} color="var(--player-primary)" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
||||
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
||||
|
||||
interface PlayerContextType extends PlayerContextValue {
|
||||
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
||||
setUIState: React.Dispatch<React.SetStateAction<UIState>>
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | null>(null)
|
||||
|
||||
export const usePlayerContext = () => {
|
||||
const context = useContext(PlayerContext)
|
||||
if (!context) {
|
||||
throw new Error('usePlayerContext must be used within a PlayerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface PlayerProviderProps {
|
||||
children: React.ReactNode
|
||||
initialVolume?: number
|
||||
initialMuted?: boolean
|
||||
initialPlaybackRate?: number
|
||||
}
|
||||
|
||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
children,
|
||||
initialVolume = 1,
|
||||
initialMuted = false,
|
||||
initialPlaybackRate = 1,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [videoState, setVideoState] = useState<VideoState>({
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
buffered: 0,
|
||||
volume: initialVolume,
|
||||
muted: initialMuted,
|
||||
playbackRate: initialPlaybackRate,
|
||||
fullscreen: false,
|
||||
pictureInPicture: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
seeking: false,
|
||||
})
|
||||
|
||||
const [uiState, setUIState] = useState<UIState>({
|
||||
controlsVisible: true,
|
||||
settingsOpen: false,
|
||||
volumeControlOpen: false,
|
||||
qualityMenuOpen: false,
|
||||
subtitleMenuOpen: false,
|
||||
})
|
||||
|
||||
const [settings, setSettings] = useState<PlayerSettings>({
|
||||
quality: null,
|
||||
subtitle: null,
|
||||
audioTrack: null,
|
||||
playbackRate: initialPlaybackRate,
|
||||
})
|
||||
|
||||
// Video controls
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play()
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause()
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (videoState.playing) {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}, [videoState.playing, play, pause])
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
if (videoRef.current) {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume))
|
||||
videoRef.current.volume = clampedVolume
|
||||
setVideoState((prev) => ({ ...prev, volume: clampedVolume }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !videoRef.current.muted
|
||||
setVideoState((prev) => ({ ...prev, muted: !prev.muted }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate
|
||||
setVideoState((prev) => ({ ...prev, playbackRate: rate }))
|
||||
setSettings((prev) => ({ ...prev, playbackRate: rate }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fullscreen & PIP
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current?.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!document.pictureInPictureElement) {
|
||||
try {
|
||||
await videoRef.current?.requestPictureInPicture()
|
||||
} catch (error) {
|
||||
console.error('PIP error:', error)
|
||||
}
|
||||
} else {
|
||||
await document.exitPictureInPicture()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// UI controls
|
||||
const showControls = useCallback(() => {
|
||||
setUIState((prev) => ({ ...prev, controlsVisible: true }))
|
||||
}, [])
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
setUIState((prev) => ({ ...prev, controlsVisible: false }))
|
||||
}, [])
|
||||
|
||||
const toggleSettings = useCallback(() => {
|
||||
setUIState((prev) => ({ ...prev, settingsOpen: !prev.settingsOpen }))
|
||||
}, [])
|
||||
|
||||
// Settings
|
||||
const setQuality = useCallback((quality: typeof settings.quality) => {
|
||||
setSettings((prev) => ({ ...prev, quality }))
|
||||
}, [])
|
||||
|
||||
const setSubtitle = useCallback((subtitle: typeof settings.subtitle) => {
|
||||
setSettings((prev) => ({ ...prev, subtitle }))
|
||||
}, [])
|
||||
|
||||
const setAudioTrack = useCallback((audioTrack: AudioTrack | null) => {
|
||||
setSettings((prev) => ({ ...prev, audioTrack }))
|
||||
}, [])
|
||||
|
||||
const value: PlayerContextType = {
|
||||
videoState,
|
||||
uiState,
|
||||
settings,
|
||||
videoRef,
|
||||
containerRef,
|
||||
setVideoState,
|
||||
setUIState,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
setPlaybackRate,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
showControls,
|
||||
hideControls,
|
||||
toggleSettings,
|
||||
setQuality,
|
||||
setSubtitle,
|
||||
setAudioTrack,
|
||||
}
|
||||
|
||||
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||
|
||||
export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
||||
const {
|
||||
videoState,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
} = usePlayerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
break
|
||||
|
||||
case 'arrowleft':
|
||||
e.preventDefault()
|
||||
seek(Math.max(0, videoState.currentTime - 5))
|
||||
break
|
||||
|
||||
case 'arrowright':
|
||||
e.preventDefault()
|
||||
seek(Math.min(videoState.duration, videoState.currentTime + 5))
|
||||
break
|
||||
|
||||
case 'j':
|
||||
e.preventDefault()
|
||||
seek(Math.max(0, videoState.currentTime - 10))
|
||||
break
|
||||
|
||||
case 'l':
|
||||
e.preventDefault()
|
||||
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
||||
break
|
||||
|
||||
case 'arrowup':
|
||||
e.preventDefault()
|
||||
setVolume(Math.min(1, videoState.volume + 0.1))
|
||||
break
|
||||
|
||||
case 'arrowdown':
|
||||
e.preventDefault()
|
||||
setVolume(Math.max(0, videoState.volume - 0.1))
|
||||
break
|
||||
|
||||
case 'm':
|
||||
e.preventDefault()
|
||||
toggleMute()
|
||||
break
|
||||
|
||||
case 'f':
|
||||
e.preventDefault()
|
||||
toggleFullscreen()
|
||||
break
|
||||
|
||||
case 'p':
|
||||
e.preventDefault()
|
||||
togglePictureInPicture()
|
||||
break
|
||||
|
||||
case '0':
|
||||
case 'home':
|
||||
e.preventDefault()
|
||||
seek(0)
|
||||
break
|
||||
|
||||
case 'end':
|
||||
e.preventDefault()
|
||||
seek(videoState.duration)
|
||||
break
|
||||
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
e.preventDefault()
|
||||
const percent = parseInt(e.key) / 10
|
||||
seek(videoState.duration * percent)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
enabled,
|
||||
videoState.currentTime,
|
||||
videoState.duration,
|
||||
videoState.volume,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
togglePictureInPicture,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, RefObject } from 'react'
|
||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||
|
||||
interface TouchData {
|
||||
startX: number
|
||||
startY: number
|
||||
startTime: number
|
||||
lastTapTime: number
|
||||
tapCount: number
|
||||
}
|
||||
|
||||
export const useTouchGestures = (containerRef: RefObject<HTMLDivElement>) => {
|
||||
const { videoState, togglePlay, seek, setVolume } = usePlayerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const touchData: TouchData = {
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
lastTapTime: 0,
|
||||
tapCount: 0,
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
const touch = e.touches[0]
|
||||
touchData.startX = touch.clientX
|
||||
touchData.startY = touch.clientY
|
||||
touchData.startTime = Date.now()
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
const touch = e.changedTouches[0]
|
||||
const endX = touch.clientX
|
||||
const endY = touch.clientY
|
||||
const endTime = Date.now()
|
||||
|
||||
const deltaX = endX - touchData.startX
|
||||
const deltaY = endY - touchData.startY
|
||||
const deltaTime = endTime - touchData.startTime
|
||||
|
||||
// Tap/Double tap detection
|
||||
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
|
||||
const timeSinceLastTap = endTime - touchData.lastTapTime
|
||||
|
||||
if (timeSinceLastTap < 400) {
|
||||
// Double tap
|
||||
touchData.tapCount++
|
||||
|
||||
if (touchData.tapCount === 2) {
|
||||
handleDoubleTap(endX, container.getBoundingClientRect())
|
||||
touchData.tapCount = 0
|
||||
}
|
||||
} else {
|
||||
// Single tap
|
||||
touchData.tapCount = 1
|
||||
setTimeout(() => {
|
||||
if (touchData.tapCount === 1) {
|
||||
togglePlay()
|
||||
}
|
||||
touchData.tapCount = 0
|
||||
}, 400)
|
||||
}
|
||||
|
||||
touchData.lastTapTime = endTime
|
||||
}
|
||||
|
||||
// Swipe detection
|
||||
if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
// Horizontal swipe - seek
|
||||
const seekAmount = (deltaX / container.clientWidth) * 30 // Max 30 seconds
|
||||
seek(Math.max(0, Math.min(videoState.duration, videoState.currentTime + seekAmount)))
|
||||
} else {
|
||||
// Vertical swipe - volume
|
||||
const volumeChange = -(deltaY / container.clientHeight) * 0.5 // Max 0.5 volume change
|
||||
setVolume(Math.max(0, Math.min(1, videoState.volume + volumeChange)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleTap = (x: number, rect: DOMRect) => {
|
||||
const relativeX = x - rect.left
|
||||
const isLeftSide = relativeX < rect.width / 2
|
||||
|
||||
if (isLeftSide) {
|
||||
// Double tap left - rewind 10 seconds
|
||||
seek(Math.max(0, videoState.currentTime - 10))
|
||||
} else {
|
||||
// Double tap right - forward 10 seconds
|
||||
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
||||
}
|
||||
|
||||
// Show feedback animation (optional - can be implemented later)
|
||||
showDoubleTapFeedback(isLeftSide)
|
||||
}
|
||||
|
||||
const showDoubleTapFeedback = (isLeft: boolean) => {
|
||||
const feedback = document.createElement('div')
|
||||
feedback.className = 'double-tap-feedback'
|
||||
feedback.style.position = 'absolute'
|
||||
feedback.style.top = '50%'
|
||||
feedback.style.left = isLeft ? '25%' : '75%'
|
||||
feedback.style.transform = 'translate(-50%, -50%)'
|
||||
feedback.style.color = 'white'
|
||||
feedback.style.fontSize = '48px'
|
||||
feedback.style.pointerEvents = 'none'
|
||||
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
|
||||
feedback.textContent = isLeft ? '« 10s' : '10s »'
|
||||
|
||||
container?.appendChild(feedback)
|
||||
setTimeout(() => feedback.remove(), 500)
|
||||
}
|
||||
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, [containerRef, videoState.currentTime, videoState.duration, videoState.volume, togglePlay, seek, setVolume])
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface IconProps {
|
||||
size?: number
|
||||
className?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export const PlayIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 5v14l11-7L8 5z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PauseIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VolumeUpIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VolumeDownIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VolumeMuteIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FullscreenIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FullscreenExitIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SettingsIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PIPIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SubtitlesIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SpeedIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M20.38 8.57l-1.23 1.85a8 8 0 01-.22 7.58H5.07A8 8 0 0115.58 6.85l1.85-1.23A10 10 0 003.35 19a2 2 0 001.72 1h13.85a2 2 0 001.74-1 10 10 0 00-.27-10.44z"
|
||||
fill={color}
|
||||
/>
|
||||
<path d="M10.59 15.41a2 2 0 002.83 0l5.66-8.49-8.49 5.66a2 2 0 000 2.83z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ForwardIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const RewindIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const LoadingIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
opacity="0.3"
|
||||
fill={color}
|
||||
/>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CheckIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill={color} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AudioIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
// Main component
|
||||
export { VideoPlayer } from './components/VideoPlayer'
|
||||
|
||||
// Context
|
||||
export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
VideoPlayerProps,
|
||||
SubtitleTrack,
|
||||
VideoQuality,
|
||||
PlayerTheme,
|
||||
VideoState,
|
||||
UIState,
|
||||
PlayerSettings,
|
||||
PlayerContextValue,
|
||||
} from './types'
|
||||
|
||||
// Utils
|
||||
export { formatTime, parseTime } from './utils/time'
|
||||
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
||||
export { initializePolyfills, features } from './utils/polyfills'
|
||||
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
||||
export { loadHls, isHlsSupported, hasNativeHlsSupport } from './utils/hlsLoader'
|
||||
|
||||
// Hooks (for advanced users)
|
||||
export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
export { useTouchGestures } from './hooks/useTouchGestures'
|
||||
@@ -0,0 +1,112 @@
|
||||
:root {
|
||||
/* Colors - Red Theme */
|
||||
--player-primary: #ef4444;
|
||||
--player-primary-hover: #dc2626;
|
||||
--player-primary-active: #b91c1c;
|
||||
--player-primary-light: rgba(239, 68, 68, 0.2);
|
||||
|
||||
/* Background Colors */
|
||||
--player-bg: #000000;
|
||||
--player-bg-controls: rgba(0, 0, 0, 0.85);
|
||||
--player-bg-overlay: rgba(0, 0, 0, 0.6);
|
||||
--player-bg-menu: rgba(20, 20, 20, 0.95);
|
||||
|
||||
/* Text Colors */
|
||||
--player-text: #ffffff;
|
||||
--player-text-secondary: #d1d5db;
|
||||
--player-text-muted: #9ca3af;
|
||||
|
||||
/* Border & Divider */
|
||||
--player-border: #374151;
|
||||
--player-divider: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Buffered & Progress */
|
||||
--player-buffered: rgba(239, 68, 68, 0.3);
|
||||
--player-progress-bg: rgba(255, 255, 255, 0.3);
|
||||
|
||||
/* Shadows */
|
||||
--player-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--player-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--player-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Transitions */
|
||||
--player-transition-fast: 150ms;
|
||||
--player-transition-normal: 250ms;
|
||||
--player-transition-slow: 400ms;
|
||||
|
||||
/* Z-index */
|
||||
--player-z-video: 1;
|
||||
--player-z-subtitle: 10;
|
||||
--player-z-controls: 20;
|
||||
--player-z-menu: 30;
|
||||
--player-z-loading: 40;
|
||||
|
||||
/* Spacing */
|
||||
--player-spacing-xs: 6px;
|
||||
--player-spacing-sm: 10px;
|
||||
--player-spacing-md: 14px;
|
||||
--player-spacing-lg: 20px;
|
||||
--player-spacing-xl: 28px;
|
||||
|
||||
/* Border Radius */
|
||||
--player-radius-sm: 4px;
|
||||
--player-radius-md: 6px;
|
||||
--player-radius-lg: 8px;
|
||||
--player-radius-full: 9999px;
|
||||
|
||||
/* Icon Sizes */
|
||||
--player-icon-sm: 20px;
|
||||
--player-icon-md: 28px;
|
||||
--player-icon-lg: 36px;
|
||||
--player-icon-xl: 56px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export interface SubtitleTrack {
|
||||
src: string
|
||||
lang: string
|
||||
label: string
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
export interface AudioTrack {
|
||||
name: string
|
||||
language: string
|
||||
url: string
|
||||
groupId: string
|
||||
default?: boolean
|
||||
autoselect?: boolean
|
||||
}
|
||||
|
||||
export interface VideoQuality {
|
||||
height: number
|
||||
label: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface PlayerTheme {
|
||||
primaryColor?: string
|
||||
accentColor?: string
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
src: string
|
||||
poster?: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
controls?: boolean
|
||||
subtitles?: SubtitleTrack[]
|
||||
theme?: PlayerTheme
|
||||
keyboardShortcuts?: boolean
|
||||
pictureInPicture?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
onPlay?: () => void
|
||||
onPause?: () => void
|
||||
onEnded?: () => void
|
||||
onTimeUpdate?: (currentTime: number) => void
|
||||
onVolumeChange?: (volume: number) => void
|
||||
onError?: (error: Error) => void
|
||||
onLoadedMetadata?: () => void
|
||||
onSeeking?: () => void
|
||||
onSeeked?: () => void
|
||||
}
|
||||
|
||||
export interface VideoState {
|
||||
playing: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
buffered: number
|
||||
volume: number
|
||||
muted: boolean
|
||||
playbackRate: number
|
||||
fullscreen: boolean
|
||||
pictureInPicture: boolean
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
seeking: boolean
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
controlsVisible: boolean
|
||||
settingsOpen: boolean
|
||||
volumeControlOpen: boolean
|
||||
qualityMenuOpen: boolean
|
||||
subtitleMenuOpen: boolean
|
||||
}
|
||||
|
||||
export interface PlayerSettings {
|
||||
quality: VideoQuality | null
|
||||
subtitle: SubtitleTrack | null
|
||||
audioTrack: AudioTrack | null
|
||||
playbackRate: number
|
||||
}
|
||||
|
||||
export interface PlayerContextValue {
|
||||
videoState: VideoState
|
||||
uiState: UIState
|
||||
settings: PlayerSettings
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
|
||||
// Video controls
|
||||
play: () => void
|
||||
pause: () => void
|
||||
togglePlay: () => void
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
toggleMute: () => void
|
||||
setPlaybackRate: (rate: number) => void
|
||||
|
||||
// Fullscreen & PIP
|
||||
toggleFullscreen: () => void
|
||||
togglePictureInPicture: () => void
|
||||
|
||||
// UI controls
|
||||
showControls: () => void
|
||||
hideControls: () => void
|
||||
toggleSettings: () => void
|
||||
|
||||
// Settings
|
||||
setQuality: (quality: VideoQuality) => void
|
||||
setSubtitle: (subtitle: SubtitleTrack | null) => void
|
||||
setAudioTrack: (audioTrack: AudioTrack | null) => void
|
||||
}
|
||||
|
||||
export type GestureType = 'tap' | 'doubleTap' | 'swipe'
|
||||
export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
|
||||
|
||||
export interface GestureEvent {
|
||||
type: GestureType
|
||||
direction?: SwipeDirection
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
@@ -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\'')
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user