feat: add configurable props for DX improvements
- Configurable keyboard shortcuts (seekSmall, seekLarge, volumeStep, disabled keys) - Configurable touch gestures (maxSeekSeconds, maxVolumeChange, doubleTapSeekSeconds) - Configurable auto-hide timeout via controlsAutoHideDelay prop - Configurable playback rates via playbackRates prop - Aspect ratio support (16:9, 4:3, 21:9, 1:1, 9:16, custom) - Extended theme system (fontFamily, borderRadius, overlayOpacity, controlsBackground, etc.) - Custom translations support via translations prop - Children/slot system (children, controlsLeftExtra, controlsRightExtra) - Ref forwarding with VideoPlayerHandle imperative API - Analytics events (onFirstPlay, onBufferStart, onBufferEnd, onQualityChange) - iOS Safari volume slider auto-hiding - SSR guards for feature detection utilities - prefers-reduced-motion CSS media query support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback, lazy, Suspense } from 'react'
|
import React, { useEffect, useRef, useState, useCallback, lazy, Suspense, type ReactNode } from 'react'
|
||||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
import { PlayPauseButton } from './controls/PlayPauseButton'
|
import { PlayPauseButton } from './controls/PlayPauseButton'
|
||||||
import { ProgressBar } from './controls/ProgressBar'
|
import { ProgressBar } from './controls/ProgressBar'
|
||||||
@@ -11,25 +11,38 @@ import { LoadingSpinner } from './overlays/LoadingSpinner'
|
|||||||
import { CenterPlayButton } from './controls/CenterPlayButton'
|
import { CenterPlayButton } from './controls/CenterPlayButton'
|
||||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
|
||||||
import { useTouchGestures } from '../hooks/useTouchGestures'
|
import { useTouchGestures } from '../hooks/useTouchGestures'
|
||||||
import type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
|
import { features } from '../utils/polyfills'
|
||||||
|
import type { SubtitleTrack, AudioTrack, VideoQuality, KeyboardShortcutConfig, TouchConfig } from '../types'
|
||||||
import './ControlsLayer.css'
|
import './ControlsLayer.css'
|
||||||
|
|
||||||
const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu })))
|
const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu })))
|
||||||
|
|
||||||
interface ControlsLayerProps {
|
interface ControlsLayerProps {
|
||||||
keyboardShortcuts?: boolean
|
keyboardShortcuts?: boolean
|
||||||
|
keyboardShortcutConfig?: KeyboardShortcutConfig
|
||||||
pictureInPicture?: boolean
|
pictureInPicture?: boolean
|
||||||
subtitles?: SubtitleTrack[]
|
subtitles?: SubtitleTrack[]
|
||||||
audioTracks?: AudioTrack[]
|
audioTracks?: AudioTrack[]
|
||||||
qualities?: VideoQuality[]
|
qualities?: VideoQuality[]
|
||||||
|
controlsAutoHideDelay?: number
|
||||||
|
playbackRates?: number[]
|
||||||
|
touchConfig?: TouchConfig
|
||||||
|
controlsLeftExtra?: ReactNode
|
||||||
|
controlsRightExtra?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||||
keyboardShortcuts = true,
|
keyboardShortcuts = true,
|
||||||
|
keyboardShortcutConfig,
|
||||||
pictureInPicture = true,
|
pictureInPicture = true,
|
||||||
subtitles = [],
|
subtitles = [],
|
||||||
audioTracks = [],
|
audioTracks = [],
|
||||||
qualities = [],
|
qualities = [],
|
||||||
|
controlsAutoHideDelay = 3000,
|
||||||
|
playbackRates,
|
||||||
|
touchConfig,
|
||||||
|
controlsLeftExtra,
|
||||||
|
controlsRightExtra,
|
||||||
}) => {
|
}) => {
|
||||||
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls, translations } =
|
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls, translations } =
|
||||||
usePlayerContext()
|
usePlayerContext()
|
||||||
@@ -61,8 +74,8 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
clearHideTimeout()
|
clearHideTimeout()
|
||||||
hideTimeoutRef.current = window.setTimeout(() => {
|
hideTimeoutRef.current = window.setTimeout(() => {
|
||||||
hideControls()
|
hideControls()
|
||||||
}, 3000)
|
}, controlsAutoHideDelay)
|
||||||
}, [autoHideEnabled, clearHideTimeout, hideControls])
|
}, [autoHideEnabled, clearHideTimeout, hideControls, controlsAutoHideDelay])
|
||||||
|
|
||||||
// Keep controls visible when not playing or when any menu is open
|
// Keep controls visible when not playing or when any menu is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,10 +156,10 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
}, [autoHideEnabled, showControls])
|
}, [autoHideEnabled, showControls])
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useKeyboardShortcuts(keyboardShortcuts)
|
useKeyboardShortcuts(keyboardShortcuts, keyboardShortcutConfig)
|
||||||
|
|
||||||
// Touch gestures
|
// Touch gestures
|
||||||
useTouchGestures(containerRef)
|
useTouchGestures(containerRef, touchConfig)
|
||||||
|
|
||||||
// Handle click for play/pause and double-click for fullscreen
|
// Handle click for play/pause and double-click for fullscreen
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
@@ -221,7 +234,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
<div className="controls-row">
|
<div className="controls-row">
|
||||||
<div className="controls-left">
|
<div className="controls-left">
|
||||||
<PlayPauseButton />
|
<PlayPauseButton />
|
||||||
<VolumeControl />
|
{features.hasVolumeControl() && <VolumeControl />}
|
||||||
{/* Time display - hidden for live broadcasts */}
|
{/* Time display - hidden for live broadcasts */}
|
||||||
{!videoState.isLiveBroadcast && <TimeDisplay />}
|
{!videoState.isLiveBroadcast && <TimeDisplay />}
|
||||||
{/* Show "LIVE" badge for live broadcasts */}
|
{/* Show "LIVE" badge for live broadcasts */}
|
||||||
@@ -231,13 +244,20 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
<span className="live-text">{translations.live}</span>
|
<span className="live-text">{translations.live}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{controlsLeftExtra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="controls-right">
|
<div className="controls-right">
|
||||||
|
{controlsRightExtra}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
|
<SettingsMenu
|
||||||
|
subtitles={subtitles}
|
||||||
|
audioTracks={audioTracks}
|
||||||
|
qualities={qualities}
|
||||||
|
playbackRates={playbackRates}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{pictureInPicture && <PIPButton />}
|
{pictureInPicture && <PIPButton />}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ interface VideoElementProps {
|
|||||||
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
|
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
|
||||||
onWaiting?: () => void
|
onWaiting?: () => void
|
||||||
onCanPlay?: () => void
|
onCanPlay?: () => void
|
||||||
|
onQualityChange?: (quality: VideoQuality) => void
|
||||||
|
onBufferStart?: () => void
|
||||||
|
onBufferEnd?: () => void
|
||||||
|
onFirstPlay?: () => void
|
||||||
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
||||||
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
|
||||||
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
|
||||||
@@ -78,12 +82,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
onPictureInPictureChange,
|
onPictureInPictureChange,
|
||||||
onWaiting,
|
onWaiting,
|
||||||
onCanPlay,
|
onCanPlay,
|
||||||
|
onQualityChange,
|
||||||
|
onBufferStart,
|
||||||
|
onBufferEnd,
|
||||||
|
onFirstPlay,
|
||||||
onAudioTracksLoaded,
|
onAudioTracksLoaded,
|
||||||
onQualityLevelsLoaded,
|
onQualityLevelsLoaded,
|
||||||
onSubtitleTracksLoaded,
|
onSubtitleTracksLoaded,
|
||||||
}) => {
|
}) => {
|
||||||
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
|
||||||
const lastClickTimeRef = React.useRef<number>(0)
|
const lastClickTimeRef = React.useRef<number>(0)
|
||||||
|
const hasPlayedRef = React.useRef(false)
|
||||||
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||||
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
|
||||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
@@ -93,8 +102,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
// Handle video events
|
// Handle video events
|
||||||
const handlePlay = useCallback(() => {
|
const handlePlay = useCallback(() => {
|
||||||
setVideoState((prev) => ({ ...prev, playing: true }))
|
setVideoState((prev) => ({ ...prev, playing: true }))
|
||||||
|
if (!hasPlayedRef.current) {
|
||||||
|
hasPlayedRef.current = true
|
||||||
|
onFirstPlay?.()
|
||||||
|
}
|
||||||
onPlay?.()
|
onPlay?.()
|
||||||
}, [setVideoState, onPlay])
|
}, [setVideoState, onPlay, onFirstPlay])
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
setVideoState((prev) => ({ ...prev, playing: false }))
|
setVideoState((prev) => ({ ...prev, playing: false }))
|
||||||
@@ -189,13 +202,15 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
|
|
||||||
const handleWaiting = useCallback(() => {
|
const handleWaiting = useCallback(() => {
|
||||||
setVideoState((prev) => ({ ...prev, loading: true }))
|
setVideoState((prev) => ({ ...prev, loading: true }))
|
||||||
|
onBufferStart?.()
|
||||||
onWaiting?.()
|
onWaiting?.()
|
||||||
}, [setVideoState, onWaiting])
|
}, [setVideoState, onWaiting, onBufferStart])
|
||||||
|
|
||||||
const handleCanPlay = useCallback(() => {
|
const handleCanPlay = useCallback(() => {
|
||||||
setVideoState((prev) => ({ ...prev, loading: false }))
|
setVideoState((prev) => ({ ...prev, loading: false }))
|
||||||
|
onBufferEnd?.()
|
||||||
onCanPlay?.()
|
onCanPlay?.()
|
||||||
}, [setVideoState, onCanPlay])
|
}, [setVideoState, onCanPlay, onBufferEnd])
|
||||||
|
|
||||||
const handleProgress = useCallback(() => {
|
const handleProgress = useCallback(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -687,6 +702,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onQualityChange?.(settings.quality)
|
||||||
|
|
||||||
let targetLevelIndex =
|
let targetLevelIndex =
|
||||||
typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined
|
typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
border-radius: var(--player-radius);
|
border-radius: var(--player-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--player-text);
|
color: var(--player-text);
|
||||||
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family: var(--player-font-family, 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
.video-player::before {
|
.video-player::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
padding-top: 56.25%;
|
padding-top: var(--player-aspect-ratio, 56.25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player > * {
|
.video-player > * {
|
||||||
|
|||||||
+371
-233
@@ -1,8 +1,8 @@
|
|||||||
import React, { useMemo, useState, useCallback } from 'react'
|
import React, { useMemo, useState, useCallback, useImperativeHandle, forwardRef } from 'react'
|
||||||
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||||
import { VideoElement } from './VideoElement'
|
import { VideoElement } from './VideoElement'
|
||||||
import { ControlsLayer } from './ControlsLayer'
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
import type { VideoPlayerProps, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
import type { VideoPlayerProps, VideoPlayerHandle, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
import { initializePolyfills } from '../utils/polyfills'
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
import './VideoPlayer.css'
|
import './VideoPlayer.css'
|
||||||
@@ -26,245 +26,383 @@ const initializePolyfillsIfNeeded = () => {
|
|||||||
// Initialize polyfills if needed
|
// Initialize polyfills if needed
|
||||||
initializePolyfillsIfNeeded()
|
initializePolyfillsIfNeeded()
|
||||||
|
|
||||||
const VideoPlayerContent: React.FC<
|
const parseAspectRatio = (ratio: VideoPlayerProps['aspectRatio']): string => {
|
||||||
VideoPlayerProps & {
|
if (!ratio) return '56.25%'
|
||||||
audioTracks: AudioTrack[]
|
if (typeof ratio === 'number') return `${ratio * 100}%`
|
||||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
const map: Record<string, string> = {
|
||||||
qualities: VideoQuality[]
|
'16:9': '56.25%',
|
||||||
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
|
'4:3': '75%',
|
||||||
hlsSubtitles: SubtitleTrack[]
|
'21:9': '42.857%',
|
||||||
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
|
'1:1': '100%',
|
||||||
|
'9:16': '177.778%',
|
||||||
}
|
}
|
||||||
> = ({
|
return map[ratio] || '56.25%'
|
||||||
src,
|
}
|
||||||
poster,
|
|
||||||
protocol = 'auto',
|
|
||||||
autoplay = false,
|
|
||||||
loop = false,
|
|
||||||
muted = false,
|
|
||||||
volume,
|
|
||||||
playbackRate,
|
|
||||||
currentTime,
|
|
||||||
crossOrigin,
|
|
||||||
preload = 'metadata',
|
|
||||||
playsInline = true,
|
|
||||||
controlsList,
|
|
||||||
controls = true,
|
|
||||||
subtitles = [],
|
|
||||||
theme,
|
|
||||||
keyboardShortcuts = true,
|
|
||||||
pictureInPicture = true,
|
|
||||||
className = '',
|
|
||||||
style,
|
|
||||||
onPlay,
|
|
||||||
onPause,
|
|
||||||
onEnded,
|
|
||||||
onTimeUpdate,
|
|
||||||
onVolumeChange,
|
|
||||||
onError,
|
|
||||||
onLoadedMetadata,
|
|
||||||
onSeeking,
|
|
||||||
onSeeked,
|
|
||||||
onProgress,
|
|
||||||
onDurationChange,
|
|
||||||
onRateChange,
|
|
||||||
onFullscreenChange,
|
|
||||||
onPictureInPictureChange,
|
|
||||||
onWaiting,
|
|
||||||
onCanPlay,
|
|
||||||
audioTracks,
|
|
||||||
onAudioTracksLoadedInternal,
|
|
||||||
qualities,
|
|
||||||
onQualityLevelsLoadedInternal,
|
|
||||||
hlsSubtitles,
|
|
||||||
onSubtitleTracksLoadedInternal,
|
|
||||||
}) => {
|
|
||||||
const { containerRef, uiState } = usePlayerContext()
|
|
||||||
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
|
|
||||||
const themedStyle = useMemo<React.CSSProperties>(() => {
|
|
||||||
if (!theme) {
|
|
||||||
return style || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssVariables: Record<string, string> = {}
|
interface VideoPlayerContentProps extends VideoPlayerProps {
|
||||||
if (theme.primaryColor) {
|
audioTracks: AudioTrack[]
|
||||||
cssVariables['--player-primary'] = theme.primaryColor
|
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||||
}
|
qualities: VideoQuality[]
|
||||||
if (theme.accentColor) {
|
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
|
||||||
cssVariables['--player-primary-hover'] = theme.accentColor
|
hlsSubtitles: SubtitleTrack[]
|
||||||
}
|
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
|
||||||
if (theme.backgroundColor) {
|
}
|
||||||
cssVariables['--player-bg'] = theme.backgroundColor
|
|
||||||
}
|
|
||||||
if (theme.textColor) {
|
|
||||||
cssVariables['--player-text'] = theme.textColor
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps>(
|
||||||
...cssVariables,
|
(
|
||||||
...(style || {}),
|
{
|
||||||
} as React.CSSProperties
|
src,
|
||||||
}, [theme, style])
|
poster,
|
||||||
|
protocol = 'auto',
|
||||||
|
autoplay = false,
|
||||||
|
loop = false,
|
||||||
|
muted = false,
|
||||||
|
volume,
|
||||||
|
playbackRate,
|
||||||
|
currentTime,
|
||||||
|
crossOrigin,
|
||||||
|
preload = 'metadata',
|
||||||
|
playsInline = true,
|
||||||
|
controlsList,
|
||||||
|
controls = true,
|
||||||
|
subtitles = [],
|
||||||
|
theme,
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
pictureInPicture = true,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
controlsAutoHideDelay = 3000,
|
||||||
|
playbackRates,
|
||||||
|
aspectRatio,
|
||||||
|
keyboardShortcutConfig,
|
||||||
|
touchConfig,
|
||||||
|
children,
|
||||||
|
controlsLeftExtra,
|
||||||
|
controlsRightExtra,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
|
onVolumeChange,
|
||||||
|
onError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onSeeking,
|
||||||
|
onSeeked,
|
||||||
|
onProgress,
|
||||||
|
onDurationChange,
|
||||||
|
onRateChange,
|
||||||
|
onFullscreenChange,
|
||||||
|
onPictureInPictureChange,
|
||||||
|
onWaiting,
|
||||||
|
onCanPlay,
|
||||||
|
onQualityChange,
|
||||||
|
onBufferStart,
|
||||||
|
onBufferEnd,
|
||||||
|
onFirstPlay,
|
||||||
|
audioTracks,
|
||||||
|
onAudioTracksLoadedInternal,
|
||||||
|
qualities,
|
||||||
|
onQualityLevelsLoadedInternal,
|
||||||
|
hlsSubtitles,
|
||||||
|
onSubtitleTracksLoadedInternal,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
containerRef,
|
||||||
|
uiState,
|
||||||
|
videoRef,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
setPlaybackRate,
|
||||||
|
} = usePlayerContext()
|
||||||
|
|
||||||
// Merge manual subtitles and HLS-detected subtitles
|
// Ref forwarding
|
||||||
const allSubtitles = [...subtitles, ...hlsSubtitles]
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
video: videoRef.current,
|
||||||
|
container: containerRef.current,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
setPlaybackRate,
|
||||||
|
}),
|
||||||
|
[videoRef, containerRef, play, pause, seek, setVolume, toggleMute, toggleFullscreen, togglePictureInPicture, setPlaybackRate]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
|
||||||
<div
|
const themedStyle = useMemo<React.CSSProperties>(() => {
|
||||||
ref={containerRef}
|
const cssVariables: Record<string, string> = {}
|
||||||
className={`video-player ${controlsHiddenClass} ${className}`}
|
|
||||||
style={themedStyle}
|
// Aspect ratio
|
||||||
tabIndex={0}
|
if (aspectRatio) {
|
||||||
>
|
cssVariables['--player-aspect-ratio'] = parseAspectRatio(aspectRatio)
|
||||||
<VideoElement
|
}
|
||||||
src={src}
|
|
||||||
poster={poster}
|
if (theme) {
|
||||||
protocol={protocol}
|
if (theme.primaryColor) {
|
||||||
autoplay={autoplay}
|
cssVariables['--player-primary'] = theme.primaryColor
|
||||||
loop={loop}
|
}
|
||||||
muted={muted}
|
if (theme.accentColor) {
|
||||||
volume={volume}
|
cssVariables['--player-primary-hover'] = theme.accentColor
|
||||||
playbackRate={playbackRate}
|
}
|
||||||
currentTime={currentTime}
|
if (theme.backgroundColor) {
|
||||||
crossOrigin={crossOrigin}
|
cssVariables['--player-bg'] = theme.backgroundColor
|
||||||
preload={preload}
|
}
|
||||||
playsInline={playsInline}
|
if (theme.textColor) {
|
||||||
controlsList={controlsList}
|
cssVariables['--player-text'] = theme.textColor
|
||||||
subtitles={subtitles}
|
}
|
||||||
onPlay={onPlay}
|
if (theme.fontFamily) {
|
||||||
onPause={onPause}
|
cssVariables['--player-font-family'] = theme.fontFamily
|
||||||
onEnded={onEnded}
|
}
|
||||||
onTimeUpdate={onTimeUpdate}
|
if (theme.borderRadius !== undefined) {
|
||||||
onVolumeChange={onVolumeChange}
|
cssVariables['--player-radius'] =
|
||||||
onError={onError}
|
typeof theme.borderRadius === 'number' ? `${theme.borderRadius}px` : theme.borderRadius
|
||||||
onLoadedMetadata={onLoadedMetadata}
|
}
|
||||||
onSeeking={onSeeking}
|
if (theme.overlayOpacity !== undefined) {
|
||||||
onSeeked={onSeeked}
|
cssVariables['--player-overlay-soft'] = `rgba(0, 0, 0, ${theme.overlayOpacity})`
|
||||||
onProgress={onProgress}
|
}
|
||||||
onDurationChange={onDurationChange}
|
if (theme.controlsBackground) {
|
||||||
onRateChange={onRateChange}
|
cssVariables['--player-surface'] = theme.controlsBackground
|
||||||
onFullscreenChange={onFullscreenChange}
|
}
|
||||||
onPictureInPictureChange={onPictureInPictureChange}
|
if (theme.textSecondaryColor) {
|
||||||
onWaiting={onWaiting}
|
cssVariables['--player-text-secondary'] = theme.textSecondaryColor
|
||||||
onCanPlay={onCanPlay}
|
}
|
||||||
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
if (theme.textMutedColor) {
|
||||||
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
cssVariables['--player-text-muted'] = theme.textMutedColor
|
||||||
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
}
|
||||||
/>
|
}
|
||||||
{controls && (
|
|
||||||
<ControlsLayer
|
if (Object.keys(cssVariables).length === 0) {
|
||||||
|
return style || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cssVariables,
|
||||||
|
...(style || {}),
|
||||||
|
} as React.CSSProperties
|
||||||
|
}, [theme, style, aspectRatio])
|
||||||
|
|
||||||
|
// Merge manual subtitles and HLS-detected subtitles
|
||||||
|
const allSubtitles = [...subtitles, ...hlsSubtitles]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`video-player ${controlsHiddenClass} ${className}`}
|
||||||
|
style={themedStyle}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<VideoElement
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
protocol={protocol}
|
||||||
|
autoplay={autoplay}
|
||||||
|
loop={loop}
|
||||||
|
muted={muted}
|
||||||
|
volume={volume}
|
||||||
|
playbackRate={playbackRate}
|
||||||
|
currentTime={currentTime}
|
||||||
|
crossOrigin={crossOrigin}
|
||||||
|
preload={preload}
|
||||||
|
playsInline={playsInline}
|
||||||
|
controlsList={controlsList}
|
||||||
|
subtitles={subtitles}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onError={onError}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
|
onSeeking={onSeeking}
|
||||||
|
onSeeked={onSeeked}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onDurationChange={onDurationChange}
|
||||||
|
onRateChange={onRateChange}
|
||||||
|
onFullscreenChange={onFullscreenChange}
|
||||||
|
onPictureInPictureChange={onPictureInPictureChange}
|
||||||
|
onWaiting={onWaiting}
|
||||||
|
onCanPlay={onCanPlay}
|
||||||
|
onQualityChange={onQualityChange}
|
||||||
|
onBufferStart={onBufferStart}
|
||||||
|
onBufferEnd={onBufferEnd}
|
||||||
|
onFirstPlay={onFirstPlay}
|
||||||
|
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
||||||
|
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
||||||
|
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
||||||
|
/>
|
||||||
|
{controls && (
|
||||||
|
<ControlsLayer
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
|
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||||
|
pictureInPicture={pictureInPicture}
|
||||||
|
subtitles={allSubtitles}
|
||||||
|
audioTracks={audioTracks}
|
||||||
|
qualities={qualities}
|
||||||
|
controlsAutoHideDelay={controlsAutoHideDelay}
|
||||||
|
playbackRates={playbackRates}
|
||||||
|
touchConfig={touchConfig}
|
||||||
|
controlsLeftExtra={controlsLeftExtra}
|
||||||
|
controlsRightExtra={controlsRightExtra}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children && (
|
||||||
|
<div className="video-player-overlay" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 'var(--player-z-controls)' as any }}>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
VideoPlayerContent.displayName = 'VideoPlayerContent'
|
||||||
|
|
||||||
|
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
protocol = 'auto',
|
||||||
|
autoplay = false,
|
||||||
|
loop = false,
|
||||||
|
muted = false,
|
||||||
|
volume,
|
||||||
|
playbackRate,
|
||||||
|
currentTime,
|
||||||
|
crossOrigin,
|
||||||
|
preload = 'metadata',
|
||||||
|
playsInline = true,
|
||||||
|
controlsList,
|
||||||
|
controls = true,
|
||||||
|
subtitles = [],
|
||||||
|
theme,
|
||||||
|
language,
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
pictureInPicture = true,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
controlsAutoHideDelay,
|
||||||
|
playbackRates,
|
||||||
|
aspectRatio,
|
||||||
|
keyboardShortcutConfig,
|
||||||
|
touchConfig,
|
||||||
|
translations: customTranslations,
|
||||||
|
children,
|
||||||
|
controlsLeftExtra,
|
||||||
|
controlsRightExtra,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
|
onVolumeChange,
|
||||||
|
onError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onSeeking,
|
||||||
|
onSeeked,
|
||||||
|
onProgress,
|
||||||
|
onDurationChange,
|
||||||
|
onRateChange,
|
||||||
|
onFullscreenChange,
|
||||||
|
onPictureInPictureChange,
|
||||||
|
onWaiting,
|
||||||
|
onCanPlay,
|
||||||
|
onQualityChange,
|
||||||
|
onBufferStart,
|
||||||
|
onBufferEnd,
|
||||||
|
onFirstPlay,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||||
|
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||||
|
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||||
|
|
||||||
|
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
||||||
|
setAudioTracks(tracks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => {
|
||||||
|
setQualities(levels)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => {
|
||||||
|
setHlsSubtitles(tracks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
||||||
|
<VideoPlayerContent
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
protocol={protocol}
|
||||||
|
autoplay={autoplay}
|
||||||
|
loop={loop}
|
||||||
|
muted={muted}
|
||||||
|
volume={volume}
|
||||||
|
playbackRate={playbackRate}
|
||||||
|
currentTime={currentTime}
|
||||||
|
crossOrigin={crossOrigin}
|
||||||
|
preload={preload}
|
||||||
|
playsInline={playsInline}
|
||||||
|
controlsList={controlsList}
|
||||||
|
controls={controls}
|
||||||
|
subtitles={subtitles}
|
||||||
|
theme={theme}
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
pictureInPicture={pictureInPicture}
|
pictureInPicture={pictureInPicture}
|
||||||
subtitles={allSubtitles}
|
className={className}
|
||||||
|
style={style}
|
||||||
|
controlsAutoHideDelay={controlsAutoHideDelay}
|
||||||
|
playbackRates={playbackRates}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||||
|
touchConfig={touchConfig}
|
||||||
|
children={children}
|
||||||
|
controlsLeftExtra={controlsLeftExtra}
|
||||||
|
controlsRightExtra={controlsRightExtra}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onError={onError}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
|
onSeeking={onSeeking}
|
||||||
|
onSeeked={onSeeked}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onDurationChange={onDurationChange}
|
||||||
|
onRateChange={onRateChange}
|
||||||
|
onFullscreenChange={onFullscreenChange}
|
||||||
|
onPictureInPictureChange={onPictureInPictureChange}
|
||||||
|
onWaiting={onWaiting}
|
||||||
|
onCanPlay={onCanPlay}
|
||||||
|
onQualityChange={onQualityChange}
|
||||||
|
onBufferStart={onBufferStart}
|
||||||
|
onBufferEnd={onBufferEnd}
|
||||||
|
onFirstPlay={onFirstPlay}
|
||||||
audioTracks={audioTracks}
|
audioTracks={audioTracks}
|
||||||
|
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||||
qualities={qualities}
|
qualities={qualities}
|
||||||
|
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
||||||
|
hlsSubtitles={hlsSubtitles}
|
||||||
|
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
||||||
/>
|
/>
|
||||||
)}
|
</PlayerProvider>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
VideoPlayer.displayName = 'VideoPlayer'
|
||||||
src,
|
|
||||||
poster,
|
|
||||||
protocol = 'auto',
|
|
||||||
autoplay = false,
|
|
||||||
loop = false,
|
|
||||||
muted = false,
|
|
||||||
volume,
|
|
||||||
playbackRate,
|
|
||||||
currentTime,
|
|
||||||
crossOrigin,
|
|
||||||
preload = 'metadata',
|
|
||||||
playsInline = true,
|
|
||||||
controlsList,
|
|
||||||
controls = true,
|
|
||||||
subtitles = [],
|
|
||||||
theme,
|
|
||||||
language,
|
|
||||||
keyboardShortcuts = true,
|
|
||||||
pictureInPicture = true,
|
|
||||||
className = '',
|
|
||||||
style,
|
|
||||||
onPlay,
|
|
||||||
onPause,
|
|
||||||
onEnded,
|
|
||||||
onTimeUpdate,
|
|
||||||
onVolumeChange,
|
|
||||||
onError,
|
|
||||||
onLoadedMetadata,
|
|
||||||
onSeeking,
|
|
||||||
onSeeked,
|
|
||||||
onProgress,
|
|
||||||
onDurationChange,
|
|
||||||
onRateChange,
|
|
||||||
onFullscreenChange,
|
|
||||||
onPictureInPictureChange,
|
|
||||||
onWaiting,
|
|
||||||
onCanPlay,
|
|
||||||
}) => {
|
|
||||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
|
||||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
|
||||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
|
||||||
|
|
||||||
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
|
||||||
setAudioTracks(tracks)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => {
|
|
||||||
setQualities(levels)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => {
|
|
||||||
setHlsSubtitles(tracks)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlayerProvider initialMuted={muted} language={language}>
|
|
||||||
<VideoPlayerContent
|
|
||||||
src={src}
|
|
||||||
poster={poster}
|
|
||||||
protocol={protocol}
|
|
||||||
autoplay={autoplay}
|
|
||||||
loop={loop}
|
|
||||||
muted={muted}
|
|
||||||
volume={volume}
|
|
||||||
playbackRate={playbackRate}
|
|
||||||
currentTime={currentTime}
|
|
||||||
crossOrigin={crossOrigin}
|
|
||||||
preload={preload}
|
|
||||||
playsInline={playsInline}
|
|
||||||
controlsList={controlsList}
|
|
||||||
controls={controls}
|
|
||||||
subtitles={subtitles}
|
|
||||||
theme={theme}
|
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
|
||||||
pictureInPicture={pictureInPicture}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
onPlay={onPlay}
|
|
||||||
onPause={onPause}
|
|
||||||
onEnded={onEnded}
|
|
||||||
onTimeUpdate={onTimeUpdate}
|
|
||||||
onVolumeChange={onVolumeChange}
|
|
||||||
onError={onError}
|
|
||||||
onLoadedMetadata={onLoadedMetadata}
|
|
||||||
onSeeking={onSeeking}
|
|
||||||
onSeeked={onSeeked}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onDurationChange={onDurationChange}
|
|
||||||
onRateChange={onRateChange}
|
|
||||||
onFullscreenChange={onFullscreenChange}
|
|
||||||
onPictureInPictureChange={onPictureInPictureChange}
|
|
||||||
onWaiting={onWaiting}
|
|
||||||
onCanPlay={onCanPlay}
|
|
||||||
audioTracks={audioTracks}
|
|
||||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
|
||||||
qualities={qualities}
|
|
||||||
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
|
||||||
hlsSubtitles={hlsSubtitles}
|
|
||||||
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
|
||||||
/>
|
|
||||||
</PlayerProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface SettingsMenuProps {
|
|||||||
subtitles?: Array<{ src: string; lang: string; label: string }>
|
subtitles?: Array<{ src: string; lang: string; label: string }>
|
||||||
audioTracks?: AudioTrack[]
|
audioTracks?: AudioTrack[]
|
||||||
qualities?: VideoQuality[]
|
qualities?: VideoQuality[]
|
||||||
|
playbackRates?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' | 'quality'
|
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' | 'quality'
|
||||||
@@ -16,6 +17,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
subtitles = [],
|
subtitles = [],
|
||||||
audioTracks = [],
|
audioTracks = [],
|
||||||
qualities = [],
|
qualities = [],
|
||||||
|
playbackRates: playbackRatesProp,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
uiState,
|
uiState,
|
||||||
@@ -31,7 +33,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentView, setCurrentView] = useState<MenuView>('main')
|
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||||
|
|
||||||
const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
const playbackRates = playbackRatesProp ?? [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface PlayerProviderProps {
|
|||||||
initialMuted?: boolean
|
initialMuted?: boolean
|
||||||
initialPlaybackRate?: number
|
initialPlaybackRate?: number
|
||||||
language?: string
|
language?: string
|
||||||
|
customTranslations?: Partial<Translations>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||||
@@ -37,12 +38,16 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
|||||||
initialMuted = false,
|
initialMuted = false,
|
||||||
initialPlaybackRate = 1,
|
initialPlaybackRate = 1,
|
||||||
language,
|
language,
|
||||||
|
customTranslations,
|
||||||
}) => {
|
}) => {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// Get translations based on language prop or browser language
|
// Get translations based on language prop or browser language, merged with custom translations
|
||||||
const translations = getTranslations(language || detectBrowserLanguage())
|
const baseTranslations = getTranslations(language || detectBrowserLanguage())
|
||||||
|
const translations = customTranslations
|
||||||
|
? { ...baseTranslations, ...customTranslations }
|
||||||
|
: baseTranslations
|
||||||
|
|
||||||
const [videoState, setVideoState] = useState<VideoState>({
|
const [videoState, setVideoState] = useState<VideoState>({
|
||||||
playing: false,
|
playing: false,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
import type { KeyboardShortcutConfig } from '../types'
|
||||||
|
|
||||||
export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
export const useKeyboardShortcuts = (enabled: boolean = true, config?: KeyboardShortcutConfig) => {
|
||||||
const {
|
const {
|
||||||
videoState,
|
videoState,
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -70,6 +71,13 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
|
|
||||||
|
const seekSmall = config?.seekSmall ?? 5
|
||||||
|
const seekLarge = config?.seekLarge ?? 10
|
||||||
|
const volumeStep = config?.volumeStep ?? 0.1
|
||||||
|
const disabled = config?.disabled ?? []
|
||||||
|
|
||||||
|
const isDisabled = (key: string) => disabled.includes(key)
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const container = containerRef?.current
|
const container = containerRef?.current
|
||||||
if (container && !isActivePlayer) {
|
if (container && !isActivePlayer) {
|
||||||
@@ -85,65 +93,79 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
const key = e.key.toLowerCase()
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
case 'k':
|
case 'k':
|
||||||
|
if (isDisabled('space') || isDisabled('k')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
togglePlay()
|
togglePlay()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'arrowleft':
|
case 'arrowleft':
|
||||||
|
if (isDisabled('arrowleft')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(Math.max(0, videoState.currentTime - 5))
|
seek(Math.max(0, videoState.currentTime - seekSmall))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'arrowright':
|
case 'arrowright':
|
||||||
|
if (isDisabled('arrowright')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(Math.min(videoState.duration, videoState.currentTime + 5))
|
seek(Math.min(videoState.duration, videoState.currentTime + seekSmall))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'j':
|
case 'j':
|
||||||
|
if (isDisabled('j')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(Math.max(0, videoState.currentTime - 10))
|
seek(Math.max(0, videoState.currentTime - seekLarge))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'l':
|
case 'l':
|
||||||
|
if (isDisabled('l')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
seek(Math.min(videoState.duration, videoState.currentTime + seekLarge))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'arrowup':
|
case 'arrowup':
|
||||||
|
if (isDisabled('arrowup')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setVolume(Math.min(1, videoState.volume + 0.1))
|
setVolume(Math.min(1, videoState.volume + volumeStep))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'arrowdown':
|
case 'arrowdown':
|
||||||
|
if (isDisabled('arrowdown')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setVolume(Math.max(0, videoState.volume - 0.1))
|
setVolume(Math.max(0, videoState.volume - volumeStep))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'm':
|
case 'm':
|
||||||
|
if (isDisabled('m')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleMute()
|
toggleMute()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'f':
|
case 'f':
|
||||||
|
if (isDisabled('f')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleFullscreen()
|
toggleFullscreen()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'p':
|
case 'p':
|
||||||
|
if (isDisabled('p')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
togglePictureInPicture()
|
togglePictureInPicture()
|
||||||
break
|
break
|
||||||
|
|
||||||
case '0':
|
case '0':
|
||||||
case 'home':
|
case 'home':
|
||||||
|
if (isDisabled('0') || isDisabled('home')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(0)
|
seek(0)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'end':
|
case 'end':
|
||||||
|
if (isDisabled('end')) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
seek(videoState.duration)
|
seek(videoState.duration)
|
||||||
break
|
break
|
||||||
@@ -157,8 +179,9 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
|||||||
case '7':
|
case '7':
|
||||||
case '8':
|
case '8':
|
||||||
case '9': {
|
case '9': {
|
||||||
|
if (isDisabled(key)) break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const percent = parseInt(e.key, 10) / 10
|
const percent = parseInt(key, 10) / 10
|
||||||
seek(videoState.duration * percent)
|
seek(videoState.duration * percent)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -183,5 +206,6 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
togglePictureInPicture,
|
togglePictureInPicture,
|
||||||
|
config,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, MutableRefObject } from 'react'
|
import { useEffect, MutableRefObject } from 'react'
|
||||||
import { usePlayerContext } from '../contexts/PlayerContext'
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
import type { TouchConfig } from '../types'
|
||||||
|
|
||||||
interface TouchData {
|
interface TouchData {
|
||||||
startX: number
|
startX: number
|
||||||
@@ -9,13 +10,17 @@ interface TouchData {
|
|||||||
tapCount: number
|
tapCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement | null>) => {
|
export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement | null>, touchConfig?: TouchConfig) => {
|
||||||
const { videoState, togglePlay, seek, setVolume } = usePlayerContext()
|
const { videoState, togglePlay, seek, setVolume } = usePlayerContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
const maxSeekSeconds = touchConfig?.maxSeekSeconds ?? 30
|
||||||
|
const maxVolumeChange = touchConfig?.maxVolumeChange ?? 0.5
|
||||||
|
const doubleTapSeekSeconds = touchConfig?.doubleTapSeekSeconds ?? 10
|
||||||
|
|
||||||
const touchData: TouchData = {
|
const touchData: TouchData = {
|
||||||
startX: 0,
|
startX: 0,
|
||||||
startY: 0,
|
startY: 0,
|
||||||
@@ -71,11 +76,11 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
|
|||||||
if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
|
if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
|
||||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
// Horizontal swipe - seek
|
// Horizontal swipe - seek
|
||||||
const seekAmount = (deltaX / container.clientWidth) * 30 // Max 30 seconds
|
const seekAmount = (deltaX / container.clientWidth) * maxSeekSeconds
|
||||||
seek(Math.max(0, Math.min(videoState.duration, videoState.currentTime + seekAmount)))
|
seek(Math.max(0, Math.min(videoState.duration, videoState.currentTime + seekAmount)))
|
||||||
} else {
|
} else {
|
||||||
// Vertical swipe - volume
|
// Vertical swipe - volume
|
||||||
const volumeChange = -(deltaY / container.clientHeight) * 0.5 // Max 0.5 volume change
|
const volumeChange = -(deltaY / container.clientHeight) * maxVolumeChange
|
||||||
setVolume(Math.max(0, Math.min(1, videoState.volume + volumeChange)))
|
setVolume(Math.max(0, Math.min(1, videoState.volume + volumeChange)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,14 +91,11 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
|
|||||||
const isLeftSide = relativeX < rect.width / 2
|
const isLeftSide = relativeX < rect.width / 2
|
||||||
|
|
||||||
if (isLeftSide) {
|
if (isLeftSide) {
|
||||||
// Double tap left - rewind 10 seconds
|
seek(Math.max(0, videoState.currentTime - doubleTapSeekSeconds))
|
||||||
seek(Math.max(0, videoState.currentTime - 10))
|
|
||||||
} else {
|
} else {
|
||||||
// Double tap right - forward 10 seconds
|
seek(Math.min(videoState.duration, videoState.currentTime + doubleTapSeekSeconds))
|
||||||
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show feedback animation (optional - can be implemented later)
|
|
||||||
showDoubleTapFeedback(isLeftSide)
|
showDoubleTapFeedback(isLeftSide)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
|
|||||||
feedback.style.fontSize = '48px'
|
feedback.style.fontSize = '48px'
|
||||||
feedback.style.pointerEvents = 'none'
|
feedback.style.pointerEvents = 'none'
|
||||||
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
|
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
|
||||||
feedback.textContent = isLeft ? '« 10s' : '10s »'
|
feedback.textContent = isLeft ? `« ${doubleTapSeekSeconds}s` : `${doubleTapSeekSeconds}s »`
|
||||||
|
|
||||||
container?.appendChild(feedback)
|
container?.appendChild(feedback)
|
||||||
setTimeout(() => feedback.remove(), 500)
|
setTimeout(() => feedback.remove(), 500)
|
||||||
@@ -121,5 +123,5 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
|
|||||||
container.removeEventListener('touchstart', handleTouchStart)
|
container.removeEventListener('touchstart', handleTouchStart)
|
||||||
container.removeEventListener('touchend', handleTouchEnd)
|
container.removeEventListener('touchend', handleTouchEnd)
|
||||||
}
|
}
|
||||||
}, [containerRef, videoState.currentTime, videoState.duration, videoState.volume, togglePlay, seek, setVolume])
|
}, [containerRef, videoState.currentTime, videoState.duration, videoState.volume, togglePlay, seek, setVolume, touchConfig])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
|||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
VideoPlayerProps,
|
VideoPlayerProps,
|
||||||
|
VideoPlayerHandle,
|
||||||
SubtitleTrack,
|
SubtitleTrack,
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
VideoQuality,
|
VideoQuality,
|
||||||
PlayerTheme,
|
PlayerTheme,
|
||||||
|
KeyboardShortcutConfig,
|
||||||
|
TouchConfig,
|
||||||
VideoState,
|
VideoState,
|
||||||
UIState,
|
UIState,
|
||||||
PlayerSettings,
|
PlayerSettings,
|
||||||
|
|||||||
@@ -67,3 +67,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.video-player *,
|
||||||
|
.video-player *::before,
|
||||||
|
.video-player *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+75
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { CSSProperties, MutableRefObject } from 'react'
|
import type { CSSProperties, MutableRefObject, ReactNode } from 'react'
|
||||||
|
import type { Translations } from '../i18n'
|
||||||
|
|
||||||
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
|
export type VideoProtocol = 'native' | 'hls' | 'rtmp' | 'dash' | 'mpegts'
|
||||||
|
|
||||||
@@ -32,6 +33,47 @@ export interface PlayerTheme {
|
|||||||
accentColor?: string
|
accentColor?: string
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
textColor?: string
|
textColor?: string
|
||||||
|
fontFamily?: string
|
||||||
|
borderRadius?: number | string
|
||||||
|
overlayOpacity?: number
|
||||||
|
controlsBackground?: string
|
||||||
|
textSecondaryColor?: string
|
||||||
|
textMutedColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardShortcutConfig {
|
||||||
|
/** Ok tuşları seek miktarı (saniye, varsayılan: 5) */
|
||||||
|
seekSmall?: number
|
||||||
|
/** J/L tuşları seek miktarı (saniye, varsayılan: 10) */
|
||||||
|
seekLarge?: number
|
||||||
|
/** Ses artırma/azaltma adımı (0-1 arası, varsayılan: 0.1) */
|
||||||
|
volumeStep?: number
|
||||||
|
/** Devre dışı bırakılacak kısayollar */
|
||||||
|
disabled?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TouchConfig {
|
||||||
|
/** Yatay swipe maksimum seek süresi (saniye, varsayılan: 30) */
|
||||||
|
maxSeekSeconds?: number
|
||||||
|
/** Dikey swipe maksimum ses değişimi (0-1 arası, varsayılan: 0.5) */
|
||||||
|
maxVolumeChange?: number
|
||||||
|
/** Çift dokunma seek süresi (saniye, varsayılan: 10) */
|
||||||
|
doubleTapSeekSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlayerHandle {
|
||||||
|
/** Video HTML elementi */
|
||||||
|
video: HTMLVideoElement | null
|
||||||
|
/** Player container elementi */
|
||||||
|
container: HTMLDivElement | null
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
seek: (time: number) => void
|
||||||
|
setVolume: (volume: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
toggleFullscreen: () => void
|
||||||
|
togglePictureInPicture: () => void
|
||||||
|
setPlaybackRate: (rate: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
@@ -41,9 +83,9 @@ export interface VideoPlayerProps {
|
|||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
volume?: number // 0-1 arası ses seviyesi
|
volume?: number
|
||||||
playbackRate?: number // Oynatma hızı (0.25, 0.5, 1, 1.5, 2, vb.)
|
playbackRate?: number
|
||||||
currentTime?: number // Başlangıç zamanı (saniye)
|
currentTime?: number
|
||||||
crossOrigin?: '' | 'anonymous' | 'use-credentials'
|
crossOrigin?: '' | 'anonymous' | 'use-credentials'
|
||||||
preload?: 'none' | 'metadata' | 'auto'
|
preload?: 'none' | 'metadata' | 'auto'
|
||||||
playsInline?: boolean
|
playsInline?: boolean
|
||||||
@@ -56,6 +98,29 @@ export interface VideoPlayerProps {
|
|||||||
pictureInPicture?: boolean
|
pictureInPicture?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
style?: CSSProperties
|
style?: CSSProperties
|
||||||
|
|
||||||
|
// Yapılandırma
|
||||||
|
/** Kontrollerin otomatik gizlenme süresi (ms, varsayılan: 3000) */
|
||||||
|
controlsAutoHideDelay?: number
|
||||||
|
/** Oynatma hızı seçenekleri (varsayılan: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]) */
|
||||||
|
playbackRates?: number[]
|
||||||
|
/** Aspect ratio (varsayılan: '16:9') */
|
||||||
|
aspectRatio?: '16:9' | '4:3' | '21:9' | '1:1' | '9:16' | number
|
||||||
|
/** Klavye kısayolları yapılandırması */
|
||||||
|
keyboardShortcutConfig?: KeyboardShortcutConfig
|
||||||
|
/** Dokunmatik gesture yapılandırması */
|
||||||
|
touchConfig?: TouchConfig
|
||||||
|
/** Özel çeviri metinleri */
|
||||||
|
translations?: Partial<Translations>
|
||||||
|
|
||||||
|
// Slot prop'ları
|
||||||
|
/** Player üzerine yerleştirilecek overlay içeriği */
|
||||||
|
children?: ReactNode
|
||||||
|
/** Kontrol çubuğu sol tarafına eklenecek butonlar */
|
||||||
|
controlsLeftExtra?: ReactNode
|
||||||
|
/** Kontrol çubuğu sağ tarafına eklenecek butonlar */
|
||||||
|
controlsRightExtra?: ReactNode
|
||||||
|
|
||||||
// Event callbacks
|
// Event callbacks
|
||||||
onPlay?: () => void
|
onPlay?: () => void
|
||||||
onPause?: () => void
|
onPause?: () => void
|
||||||
@@ -73,6 +138,12 @@ export interface VideoPlayerProps {
|
|||||||
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
|
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
|
||||||
onWaiting?: () => void
|
onWaiting?: () => void
|
||||||
onCanPlay?: () => void
|
onCanPlay?: () => void
|
||||||
|
|
||||||
|
// Ek analytics event'leri
|
||||||
|
onQualityChange?: (quality: VideoQuality) => void
|
||||||
|
onBufferStart?: () => void
|
||||||
|
onBufferEnd?: () => void
|
||||||
|
onFirstPlay?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoState {
|
export interface VideoState {
|
||||||
|
|||||||
@@ -123,11 +123,14 @@ export const initializePolyfills = (): PolyfillDiagnostics => {
|
|||||||
/**
|
/**
|
||||||
* Feature detection utilities
|
* Feature detection utilities
|
||||||
*/
|
*/
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
|
||||||
export const features = {
|
export const features = {
|
||||||
/**
|
/**
|
||||||
* Check if browser supports HLS natively
|
* Check if browser supports HLS natively
|
||||||
*/
|
*/
|
||||||
hasNativeHLS: (): boolean => {
|
hasNativeHLS: (): boolean => {
|
||||||
|
if (!isBrowser) return false
|
||||||
const video = document.createElement('video')
|
const video = document.createElement('video')
|
||||||
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||||
},
|
},
|
||||||
@@ -143,6 +146,7 @@ export const features = {
|
|||||||
* Check if Picture-in-Picture is truly supported
|
* Check if Picture-in-Picture is truly supported
|
||||||
*/
|
*/
|
||||||
hasPIP: (): boolean => {
|
hasPIP: (): boolean => {
|
||||||
|
if (!isBrowser) return false
|
||||||
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
|
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,6 +154,7 @@ export const features = {
|
|||||||
* Check if Fullscreen API is supported
|
* Check if Fullscreen API is supported
|
||||||
*/
|
*/
|
||||||
hasFullscreen: (): boolean => {
|
hasFullscreen: (): boolean => {
|
||||||
|
if (!isBrowser) return false
|
||||||
return !!(
|
return !!(
|
||||||
document.fullscreenEnabled ||
|
document.fullscreenEnabled ||
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -165,6 +170,7 @@ export const features = {
|
|||||||
* Check if touch events are supported (mobile device)
|
* Check if touch events are supported (mobile device)
|
||||||
*/
|
*/
|
||||||
hasTouch: (): boolean => {
|
hasTouch: (): boolean => {
|
||||||
|
if (!isBrowser) return false
|
||||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -172,6 +178,7 @@ export const features = {
|
|||||||
* Detect iOS Safari
|
* Detect iOS Safari
|
||||||
*/
|
*/
|
||||||
isIOSSafari: (): boolean => {
|
isIOSSafari: (): boolean => {
|
||||||
|
if (!isBrowser) return false
|
||||||
const ua = navigator.userAgent
|
const ua = navigator.userAgent
|
||||||
const iOS = /iPad|iPhone|iPod/.test(ua)
|
const iOS = /iPad|iPhone|iPod/.test(ua)
|
||||||
const webkit = /WebKit/.test(ua)
|
const webkit = /WebKit/.test(ua)
|
||||||
|
|||||||
Reference in New Issue
Block a user