Initial commit: modern React video player library

Add all source files for a feature-rich, reusable video player built with React, TypeScript, and Vite. Includes core components, context, hooks, utilities, styles, demo app, and configuration files.
This commit is contained in:
hibna
2025-10-29 07:49:06 +03:00
parent d68df70124
commit b57b24d051
47 changed files with 4414 additions and 0 deletions
+73
View File
@@ -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);
}
}
+178
View File
@@ -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>
)
}
+26
View File
@@ -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;
}
+406
View File
@@ -0,0 +1,406 @@
import React, { useEffect, useCallback, useState } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
import type { SubtitleTrack, AudioTrack } from '../types'
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
import { getHlsAudioTracks, setHlsAudioTrack } from '../utils/hlsLoader'
import './VideoElement.css'
interface VideoElementProps {
src: string
poster?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
subtitles?: SubtitleTrack[]
onPlay?: () => void
onPause?: () => void
onEnded?: () => void
onTimeUpdate?: (currentTime: number) => void
onVolumeChange?: (volume: number) => void
onError?: (error: Error) => void
onLoadedMetadata?: () => void
onSeeking?: () => void
onSeeked?: () => void
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
}
export const VideoElement: React.FC<VideoElementProps> = ({
src,
poster,
autoplay = false,
loop = false,
muted = false,
subtitles = [],
onPlay,
onPause,
onEnded,
onTimeUpdate,
onVolumeChange,
onError,
onLoadedMetadata,
onSeeking,
onSeeked,
onAudioTracksLoaded,
}) => {
const { videoRef, containerRef, setVideoState, toggleFullscreen, settings } = usePlayerContext()
const lastClickTimeRef = React.useRef<number>(0)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
// Handle video events
const handlePlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: true }))
onPlay?.()
}, [setVideoState, onPlay])
const handlePause = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: false }))
onPause?.()
}, [setVideoState, onPause])
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current
if (!video) return
const currentTime = video.currentTime
const buffered = video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1) : 0
setVideoState((prev) => ({
...prev,
currentTime,
buffered,
}))
onTimeUpdate?.(currentTime)
}, [videoRef, setVideoState, onTimeUpdate])
const handleLoadedMetadata = useCallback(() => {
const video = videoRef.current
if (!video) return
setVideoState((prev) => ({
...prev,
duration: video.duration,
volume: video.volume,
muted: video.muted,
}))
onLoadedMetadata?.()
}, [videoRef, setVideoState, onLoadedMetadata])
const handleVolumeChange = useCallback(() => {
const video = videoRef.current
if (!video) return
setVideoState((prev) => ({
...prev,
volume: video.volume,
muted: video.muted,
}))
onVolumeChange?.(video.volume)
}, [videoRef, setVideoState, onVolumeChange])
const handleSeeking = useCallback(() => {
setVideoState((prev) => ({ ...prev, seeking: true }))
onSeeking?.()
}, [setVideoState, onSeeking])
const handleSeeked = useCallback(() => {
setVideoState((prev) => ({ ...prev, seeking: false }))
onSeeked?.()
}, [setVideoState, onSeeked])
const handleWaiting = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: true }))
}, [setVideoState])
const handleCanPlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: false }))
}, [setVideoState])
const handleEnded = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: false }))
onEnded?.()
}, [setVideoState, onEnded])
const handleError = useCallback(() => {
const video = videoRef.current
if (!video || !video.error) return
let errorMessage = `Video error: ${video.error.message}`
// Check if it's a CORS-related error
const videoError = video.error
if (videoError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED ||
videoError.code === MediaError.MEDIA_ERR_NETWORK) {
// Could be a CORS issue
const corsMessage = getCORSErrorMessage(video.src || src)
console.error(corsMessage)
errorMessage = `Failed to load video. This might be a CORS issue. Check console for details.`
}
const error = new Error(errorMessage)
setVideoState((prev) => ({ ...prev, error, loading: false }))
onError?.(error)
}, [videoRef, setVideoState, onError, src])
// Handle double-click on video for fullscreen toggle
const handleVideoClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
const now = Date.now()
const timeSinceLastClick = now - lastClickTimeRef.current
if (timeSinceLastClick < 300) {
// Double click - toggle fullscreen
e.preventDefault()
console.log('🖱️ [Video Click] Double-click detected, toggling fullscreen')
toggleFullscreen()
lastClickTimeRef.current = 0
} else {
// Single click - record time
lastClickTimeRef.current = now
console.log('🖱️ [Video Click] Single click detected')
}
},
[toggleFullscreen]
)
// Handle fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
const isFullscreen = !!document.fullscreenElement
setVideoState((prev) => ({ ...prev, fullscreen: isFullscreen }))
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [setVideoState])
// Handle PIP changes
useEffect(() => {
const handlePIPChange = () => {
const isPIP = !!document.pictureInPictureElement
setVideoState((prev) => ({ ...prev, pictureInPicture: isPIP }))
}
document.addEventListener('enterpictureinpicture', handlePIPChange)
document.addEventListener('leavepictureinpicture', handlePIPChange)
return () => {
document.removeEventListener('enterpictureinpicture', handlePIPChange)
document.removeEventListener('leavepictureinpicture', handlePIPChange)
}
}, [setVideoState])
// Detect HLS source and load hls.js if needed
useEffect(() => {
const video = videoRef.current
if (!video) return
// Validate video URL first
const validation = validateVideoURL(src)
if (!validation.valid) {
const error = new Error(validation.error || 'Invalid video URL')
setVideoState((prev) => ({ ...prev, error, loading: false }))
onError?.(error)
return
}
// Log warning if external URL
if (validation.warning) {
console.warn(`⚠️ [Video] ${validation.warning}`)
}
const isHLS = src.includes('.m3u8')
let cleanupFn: (() => void) | null = null
const setupHls = async () => {
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
// Browser doesn't support HLS natively, load hls.js
try {
// Dynamic import with CDN fallback
const { loadHls, isHlsSupported } = await import('../utils/hlsLoader')
const Hls = await loadHls()
if (!isHlsSupported(Hls)) {
throw new Error('HLS.js is not supported in this browser')
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
})
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('✅ [HLS] Manifest parsed successfully')
// Extract audio tracks after manifest is parsed
// Sometimes audio tracks are not immediately available, so we try with a small delay
setTimeout(() => {
const tracks = getHlsAudioTracks(hls)
if (tracks.length > 0) {
console.log(`✅ [HLS] Found ${tracks.length} audio tracks:`, tracks)
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
} else {
console.log('️ [HLS] No audio tracks found in manifest')
}
}, 100)
if (autoplay) {
video.play().catch((err) => {
console.warn('⚠️ [HLS] Autoplay prevented:', err)
})
}
})
// Also listen to audio track updates
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_event: any, data: any) => {
console.log('🔄 [HLS] Audio tracks updated:', data)
const tracks = getHlsAudioTracks(hls)
if (tracks.length > 0) {
console.log(`✅ [HLS] Found ${tracks.length} audio tracks after update:`, tracks)
setAvailableAudioTracks(tracks)
onAudioTracksLoaded?.(tracks)
}
})
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
console.error('❌ [HLS] Error:', data)
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('❌ [HLS] Fatal network error, trying to recover...')
hls.startLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('❌ [HLS] Fatal media error, trying to recover...')
hls.recoverMediaError()
break
default:
console.error('❌ [HLS] Fatal error, cannot recover')
handleError()
break
}
}
})
// Store hls instance for cleanup
;(video as any).__hlsInstance = hls
// Setup cleanup function
cleanupFn = () => {
console.log('🧹 [HLS] Cleaning up HLS instance...')
if (hls) {
hls.destroy()
}
delete (video as any).__hlsInstance
}
} catch (err) {
console.error('❌ [HLS] Failed to load or initialize hls.js:', err)
let error: Error
if (err instanceof Error && isCORSError(err)) {
const corsMessage = getCORSErrorMessage(src)
console.error(corsMessage)
error = new Error('Failed to load HLS stream. This might be a CORS issue. Check console for details.')
} else {
error = err instanceof Error ? err : new Error('Failed to load HLS')
}
setVideoState((prev) => ({
...prev,
error,
loading: false,
}))
onError?.(error)
}
} else {
// Native support or regular video
video.src = src
if (autoplay) {
video.play().catch((err) => {
console.warn('⚠️ [Video] Autoplay prevented:', err)
})
}
}
}
setupHls()
// Cleanup function
return () => {
if (cleanupFn) {
cleanupFn()
}
// Also check for any lingering HLS instance
if ((video as any).__hlsInstance) {
const hls = (video as any).__hlsInstance
if (hls && typeof hls.destroy === 'function') {
hls.destroy()
}
delete (video as any).__hlsInstance
}
}
}, [src, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded])
// Handle audio track changes
useEffect(() => {
const video = videoRef.current
if (!video || !settings.audioTrack) return
const hlsInstance = (video as any).__hlsInstance
if (!hlsInstance) return
// Find the index of the selected audio track
const trackIndex = availableAudioTracks.findIndex(
(track) => track.language === settings.audioTrack?.language
)
if (trackIndex !== -1) {
setHlsAudioTrack(hlsInstance, trackIndex)
console.log(`✅ [Video] Audio track changed to: ${settings.audioTrack.name}`)
}
}, [settings.audioTrack, availableAudioTracks, videoRef])
return (
<div ref={containerRef} className="video-container">
<video
ref={videoRef}
className="video-element"
poster={poster}
loop={loop}
muted={muted}
playsInline
preload="metadata"
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onVolumeChange={handleVolumeChange}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onWaiting={handleWaiting}
onCanPlay={handleCanPlay}
onEnded={handleEnded}
onError={handleError}
onClick={handleVideoClick}
>
{subtitles.map((subtitle, index) => (
<track
key={index}
kind="subtitles"
src={subtitle.src}
srcLang={subtitle.lang}
label={subtitle.label}
default={subtitle.default}
/>
))}
</video>
</div>
)
}
+63
View File
@@ -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;
}
+89
View File
@@ -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>
)
}
+55
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+112
View File
@@ -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);
}
}
+116
View File
@@ -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>
)
}
+24
View File
@@ -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;
}
}
+16
View File
@@ -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>
)
}
+104
View File
@@ -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;
}
}
+67
View File
@@ -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>
)
}
+195
View File
@@ -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;
}
}
+201
View File
@@ -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>
)
}
+184
View File
@@ -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>
}
+120
View File
@@ -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,
])
}
+125
View File
@@ -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])
}
+240
View File
@@ -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>
)
+28
View File
@@ -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'
+112
View File
@@ -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;
}
}
+123
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
/**
* CORS helper utilities for video loading
*/
export interface CORSCheckResult {
supported: boolean
error?: string
needsProxy: boolean
}
/**
* Check if a video URL supports CORS and Range Requests
*/
export const checkVideoCORS = async (url: string): Promise<CORSCheckResult> => {
try {
// Make a HEAD request to check headers
const response = await fetch(url, {
method: 'HEAD',
mode: 'cors',
})
const corsHeader = response.headers.get('Access-Control-Allow-Origin')
const rangeHeader = response.headers.get('Accept-Ranges')
if (!corsHeader && !response.ok) {
return {
supported: false,
error: 'CORS not enabled on video server',
needsProxy: true,
}
}
if (!rangeHeader || rangeHeader === 'none') {
console.warn('⚠️ [CORS] Server does not support Range Requests. Seeking may not work properly.')
}
return {
supported: true,
needsProxy: false,
}
} catch (error) {
// CORS error or network error
if (error instanceof TypeError && error.message.includes('CORS')) {
return {
supported: false,
error: 'CORS blocked by browser',
needsProxy: true,
}
}
return {
supported: false,
error: error instanceof Error ? error.message : 'Unknown error',
needsProxy: true,
}
}
}
/**
* Check if URL is from the same origin
*/
export const isSameOrigin = (url: string): boolean => {
try {
const urlObj = new URL(url, window.location.href)
return urlObj.origin === window.location.origin
} catch {
return false
}
}
/**
* Check if URL is a blob or data URL
*/
export const isBlobOrDataURL = (url: string): boolean => {
return url.startsWith('blob:') || url.startsWith('data:')
}
/**
* Validate video URL and provide helpful error messages
*/
export const validateVideoURL = (url: string): { valid: boolean; error?: string; warning?: string } => {
if (!url || url.trim() === '') {
return {
valid: false,
error: 'Video URL is empty',
}
}
// Check if it's a valid URL
try {
new URL(url, window.location.href)
} catch {
return {
valid: false,
error: 'Invalid video URL format',
}
}
// Same origin - no CORS issues
if (isSameOrigin(url)) {
return { valid: true }
}
// Blob or data URL - no CORS issues
if (isBlobOrDataURL(url)) {
return { valid: true }
}
// External URL - potential CORS issues
return {
valid: true,
warning: 'External video URL detected. Ensure server has proper CORS headers.',
}
}
/**
* Get CORS error message with helpful suggestions
*/
export const getCORSErrorMessage = (url: string): string => {
const isExternal = !isSameOrigin(url) && !isBlobOrDataURL(url)
if (!isExternal) {
return 'Failed to load video. Please check the URL.'
}
return `
❌ CORS Error: Unable to load video from external source.
The video server at "${new URL(url).origin}" does not allow cross-origin requests.
To fix this issue:
1. Add CORS headers to your video server:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Allow-Headers: Range
2. Use a proxy server to bypass CORS restrictions
3. Host the video on the same domain as your application
4. Use a CDN that supports CORS (e.g., Cloudflare, AWS CloudFront)
`.trim()
}
/**
* Check if error is CORS-related
*/
export const isCORSError = (error: Error): boolean => {
const message = error.message.toLowerCase()
return (
message.includes('cors') ||
message.includes('cross-origin') ||
message.includes('blocked by cors policy') ||
message.includes('no \'access-control-allow-origin\'')
)
}
+139
View File
@@ -0,0 +1,139 @@
/**
* HLS.js dynamic loader with CDN fallback
* Handles loading hls.js from npm or CDN
*/
import type { AudioTrack } from '../types'
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
/**
* Load hls.js from CDN as fallback
*/
const loadHlsFromCDN = (): Promise<any> => {
return new Promise((resolve, reject) => {
// Check if already loaded globally
if (typeof (window as any).Hls !== 'undefined') {
resolve((window as any).Hls)
return
}
const script = document.createElement('script')
script.src = HLS_CDN_URL
script.async = true
script.onload = () => {
if (typeof (window as any).Hls !== 'undefined') {
console.log('✅ [HLS Loader] Loaded hls.js from CDN')
resolve((window as any).Hls)
} else {
reject(new Error('HLS.js CDN loaded but Hls global not found'))
}
}
script.onerror = () => {
reject(new Error('Failed to load hls.js from CDN'))
}
document.head.appendChild(script)
})
}
/**
* Load hls.js with npm fallback to CDN
*/
export const loadHls = async (): Promise<any> => {
try {
// Try loading from npm package first
console.log('🔄 [HLS Loader] Attempting to load hls.js from npm package...')
const hlsModule = await import('hls.js')
console.log('✅ [HLS Loader] Loaded hls.js from npm package')
return hlsModule.default
} catch (npmError) {
console.warn('⚠️ [HLS Loader] Failed to load hls.js from npm, trying CDN fallback...', npmError)
try {
// Fallback to CDN
const Hls = await loadHlsFromCDN()
return Hls
} catch (cdnError) {
console.error('❌ [HLS Loader] Failed to load hls.js from both npm and CDN')
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
}
}
}
/**
* Check if HLS.js is supported in current browser
*/
export const isHlsSupported = (Hls: any): boolean => {
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
}
/**
* Check if browser has native HLS support (Safari)
*/
export const hasNativeHlsSupport = (): boolean => {
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
}
/**
* Extract audio tracks from HLS instance
*/
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
try {
if (!hls) {
console.warn('⚠️ [HLS Loader] HLS instance is null or undefined')
return []
}
// Check if audioTracks property exists
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
console.warn('⚠️ [HLS Loader] audioTracks not available or not an array:', hls.audioTracks)
return []
}
console.log('🔍 [HLS Loader] Raw audio tracks from HLS:', hls.audioTracks)
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
const audioTrack = {
name: track.name || track.label || `Audio ${index + 1}`,
language: track.lang || track.language || 'unknown',
url: track.url || '',
groupId: track.groupId || 'audio',
default: track.default || false,
autoselect: track.autoselect || false,
}
console.log(`🎵 [HLS Loader] Parsed audio track ${index}:`, audioTrack)
return audioTrack
})
return audioTracks
} catch (error) {
console.error('❌ [HLS Loader] Error extracting audio tracks:', error)
return []
}
}
/**
* Set active audio track in HLS instance
*/
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
try {
if (!hls || !hls.audioTracks) {
console.warn('⚠️ [HLS Loader] HLS instance or audioTracks not available')
return
}
if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) {
console.warn('⚠️ [HLS Loader] Invalid audio track index:', audioTrackIndex)
return
}
hls.audioTrack = audioTrackIndex
console.log(`✅ [HLS Loader] Audio track set to index ${audioTrackIndex}`)
} catch (error) {
console.error('❌ [HLS Loader] Error setting audio track:', error)
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { AudioTrack } from '../types'
/**
* Parses M3U8 manifest to extract audio tracks
*/
export const parseM3U8AudioTracks = (manifestContent: string): AudioTrack[] => {
const audioTracks: AudioTrack[] = []
const lines = manifestContent.split('\n')
for (const line of lines) {
if (line.startsWith('#EXT-X-MEDIA:TYPE=AUDIO')) {
const track = parseAudioMediaTag(line)
if (track) {
audioTracks.push(track)
}
}
}
return audioTracks
}
/**
* Parses a single #EXT-X-MEDIA:TYPE=AUDIO line
* Example: #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=NO,URI="audio_en.m3u8"
*/
const parseAudioMediaTag = (line: string): AudioTrack | null => {
try {
const attributes: Record<string, string> = {}
// Extract all key-value pairs
const regex = /(\w+(?:-\w+)*)=("(?:[^"\\]|\\.)*"|[^,]+)/g
let match
while ((match = regex.exec(line)) !== null) {
const key = match[1]
let value = match[2]
// Remove quotes if present
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1)
}
attributes[key] = value
}
// Only process if it's an AUDIO type
if (attributes['TYPE'] !== 'AUDIO') {
return null
}
// Extract required fields
const name = attributes['NAME']
const language = attributes['LANGUAGE'] || attributes['LANG'] || 'unknown'
const uri = attributes['URI']
const groupId = attributes['GROUP-ID'] || 'audio'
const defaultTrack = attributes['DEFAULT'] === 'YES'
const autoselect = attributes['AUTOSELECT'] === 'YES'
if (!name || !uri) {
console.warn('⚠️ [M3U8 Parser] Audio track missing NAME or URI:', line)
return null
}
return {
name,
language,
url: uri,
groupId,
default: defaultTrack,
autoselect,
}
} catch (error) {
console.error('❌ [M3U8 Parser] Error parsing audio track:', line, error)
return null
}
}
/**
* Fetches and parses M3U8 manifest from URL
*/
export const fetchAndParseM3U8 = async (url: string): Promise<AudioTrack[]> => {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch M3U8: ${response.statusText}`)
}
const manifestContent = await response.text()
return parseM3U8AudioTracks(manifestContent)
} catch (error) {
console.error('❌ [M3U8 Parser] Error fetching M3U8:', error)
return []
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Polyfills for older browser support
* Ensures compatibility with browsers that don't support modern APIs
*/
/**
* Polyfill for Fullscreen API
* Handles vendor prefixes for older browsers
*/
export const setupFullscreenPolyfill = () => {
if (!document.exitFullscreen) {
// @ts-ignore - Legacy API
document.exitFullscreen = document.webkitExitFullscreen ||
// @ts-ignore
document.mozCancelFullScreen ||
// @ts-ignore
document.msExitFullscreen
}
if (!Element.prototype.requestFullscreen) {
// @ts-ignore - Legacy API
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullscreen ||
// @ts-ignore
Element.prototype.mozRequestFullScreen ||
// @ts-ignore
Element.prototype.msRequestFullscreen
}
// Fullscreen change event polyfill
if (!('onfullscreenchange' in document)) {
const events = ['webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
events.forEach(event => {
document.addEventListener(event, () => {
const fullscreenChangeEvent = new Event('fullscreenchange')
document.dispatchEvent(fullscreenChangeEvent)
})
})
}
// fullscreenElement polyfill
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
Object.defineProperty(document, 'fullscreenElement', {
get: function() {
// @ts-ignore
return this.webkitFullscreenElement ||
// @ts-ignore
this.mozFullScreenElement ||
// @ts-ignore
this.msFullscreenElement
}
})
}
}
/**
* Polyfill for Picture-in-Picture API
* Checks if PIP is supported
*/
export const setupPIPPolyfill = () => {
// Check if PIP is supported
if (!('pictureInPictureEnabled' in document)) {
Object.defineProperty(document, 'pictureInPictureEnabled', {
get: function() {
// PIP not supported in this browser
return false
}
})
}
}
/**
* Promise polyfill check
* Modern browsers should have Promise, but we check anyway
*/
export const checkPromiseSupport = (): boolean => {
return typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1
}
/**
* Fetch API polyfill check
*/
export const checkFetchSupport = (): boolean => {
return typeof fetch !== 'undefined'
}
/**
* Initialize all polyfills
* Call this once when the app loads
*/
export const initializePolyfills = () => {
try {
setupFullscreenPolyfill()
setupPIPPolyfill()
// Check critical API support
if (!checkPromiseSupport()) {
console.warn('[VideoPlayer] Promise not supported. Please add Promise polyfill.')
}
if (!checkFetchSupport()) {
console.warn('[VideoPlayer] Fetch API not supported. Subtitle loading may fail.')
}
// Check for MediaSource API (required for HLS.js)
if (typeof MediaSource === 'undefined') {
console.warn('[VideoPlayer] MediaSource API not supported. HLS streaming will not work.')
}
console.log('✅ [VideoPlayer] Polyfills initialized successfully')
} catch (error) {
console.error('[VideoPlayer] Error initializing polyfills:', error)
}
}
/**
* Feature detection utilities
*/
export const features = {
/**
* Check if browser supports HLS natively
*/
hasNativeHLS: (): boolean => {
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
},
/**
* Check if browser supports MSE (required for HLS.js)
*/
hasMSE: (): boolean => {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"')
},
/**
* Check if Picture-in-Picture is truly supported
*/
hasPIP: (): boolean => {
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
},
/**
* Check if Fullscreen API is supported
*/
hasFullscreen: (): boolean => {
return !!(
document.fullscreenEnabled ||
// @ts-ignore
document.webkitFullscreenEnabled ||
// @ts-ignore
document.mozFullScreenEnabled ||
// @ts-ignore
document.msFullscreenEnabled
)
},
/**
* Check if touch events are supported (mobile device)
*/
hasTouch: (): boolean => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
},
/**
* Detect iOS Safari
*/
isIOSSafari: (): boolean => {
const ua = navigator.userAgent
const iOS = /iPad|iPhone|iPod/.test(ua)
const webkit = /WebKit/.test(ua)
return iOS && webkit && !/CriOS|FxiOS|OPiOS|mercury/.test(ua)
},
/**
* Check if programmatic volume control is supported (not on iOS)
*/
hasVolumeControl: (): boolean => {
return !features.isIOSSafari()
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Parse SRT subtitle format to WebVTT
*/
export const parseSRT = (srtContent: string): string => {
const lines = srtContent.trim().split('\n')
let vttContent = 'WEBVTT\n\n'
let i = 0
while (i < lines.length) {
// Skip subtitle number
if (/^\d+$/.test(lines[i].trim())) {
i++
}
// Parse timestamp line
if (lines[i] && lines[i].includes('-->')) {
const timeLine = lines[i].replace(/,/g, '.') // SRT uses comma, VTT uses dot
vttContent += timeLine + '\n'
i++
// Add subtitle text
while (i < lines.length && lines[i].trim() !== '') {
vttContent += lines[i] + '\n'
i++
}
vttContent += '\n'
}
i++
}
return vttContent
}
/**
* Create a blob URL from subtitle content
*/
export const createSubtitleBlobURL = (content: string, format: 'vtt' | 'srt'): string => {
const vttContent = format === 'srt' ? parseSRT(content) : content
const blob = new Blob([vttContent], { type: 'text/vtt' })
return URL.createObjectURL(blob)
}
/**
* Fetch and parse subtitle file
*/
export const fetchSubtitle = async (url: string): Promise<string> => {
try {
const response = await fetch(url)
const content = await response.text()
// Detect format
if (url.endsWith('.srt')) {
return parseSRT(content)
}
return content
} catch (error) {
console.error('Failed to fetch subtitle:', error)
throw error
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Format seconds to MM:SS or HH:MM:SS
*/
export const formatTime = (seconds: number): string => {
if (isNaN(seconds) || !isFinite(seconds)) {
return '0:00'
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
/**
* Parse time string (MM:SS or HH:MM:SS) to seconds
*/
export const parseTime = (timeString: string): number => {
const parts = timeString.split(':').map(Number)
if (parts.length === 2) {
// MM:SS
return parts[0] * 60 + parts[1]
} else if (parts.length === 3) {
// HH:MM:SS
return parts[0] * 3600 + parts[1] * 60 + parts[2]
}
return 0
}