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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user