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:
hibna
2026-02-12 19:23:54 +03:00
parent 73d5d65d2b
commit 58a405d895
12 changed files with 572 additions and 273 deletions
+28 -8
View File
@@ -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 { PlayPauseButton } from './controls/PlayPauseButton'
import { ProgressBar } from './controls/ProgressBar'
@@ -11,25 +11,38 @@ import { LoadingSpinner } from './overlays/LoadingSpinner'
import { CenterPlayButton } from './controls/CenterPlayButton'
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
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'
const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu })))
interface ControlsLayerProps {
keyboardShortcuts?: boolean
keyboardShortcutConfig?: KeyboardShortcutConfig
pictureInPicture?: boolean
subtitles?: SubtitleTrack[]
audioTracks?: AudioTrack[]
qualities?: VideoQuality[]
controlsAutoHideDelay?: number
playbackRates?: number[]
touchConfig?: TouchConfig
controlsLeftExtra?: ReactNode
controlsRightExtra?: ReactNode
}
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
keyboardShortcuts = true,
keyboardShortcutConfig,
pictureInPicture = true,
subtitles = [],
audioTracks = [],
qualities = [],
controlsAutoHideDelay = 3000,
playbackRates,
touchConfig,
controlsLeftExtra,
controlsRightExtra,
}) => {
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls, translations } =
usePlayerContext()
@@ -61,8 +74,8 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
clearHideTimeout()
hideTimeoutRef.current = window.setTimeout(() => {
hideControls()
}, 3000)
}, [autoHideEnabled, clearHideTimeout, hideControls])
}, controlsAutoHideDelay)
}, [autoHideEnabled, clearHideTimeout, hideControls, controlsAutoHideDelay])
// Keep controls visible when not playing or when any menu is open
useEffect(() => {
@@ -143,10 +156,10 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
}, [autoHideEnabled, showControls])
// Keyboard shortcuts
useKeyboardShortcuts(keyboardShortcuts)
useKeyboardShortcuts(keyboardShortcuts, keyboardShortcutConfig)
// Touch gestures
useTouchGestures(containerRef)
useTouchGestures(containerRef, touchConfig)
// Handle click for play/pause and double-click for fullscreen
const handleClick = useCallback(
@@ -221,7 +234,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
<div className="controls-row">
<div className="controls-left">
<PlayPauseButton />
<VolumeControl />
{features.hasVolumeControl() && <VolumeControl />}
{/* Time display - hidden for live broadcasts */}
{!videoState.isLiveBroadcast && <TimeDisplay />}
{/* Show "LIVE" badge for live broadcasts */}
@@ -231,13 +244,20 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
<span className="live-text">{translations.live}</span>
</div>
)}
{controlsLeftExtra}
</div>
<div className="controls-right">
{controlsRightExtra}
<div style={{ position: 'relative' }}>
<SettingsButton />
<Suspense fallback={null}>
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
<SettingsMenu
subtitles={subtitles}
audioTracks={audioTracks}
qualities={qualities}
playbackRates={playbackRates}
/>
</Suspense>
</div>
{pictureInPicture && <PIPButton />}
+20 -3
View File
@@ -42,6 +42,10 @@ interface VideoElementProps {
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
onWaiting?: () => void
onCanPlay?: () => void
onQualityChange?: (quality: VideoQuality) => void
onBufferStart?: () => void
onBufferEnd?: () => void
onFirstPlay?: () => void
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
onQualityLevelsLoaded?: (qualities: VideoQuality[]) => void
onSubtitleTracksLoaded?: (tracks: SubtitleTrack[]) => void
@@ -78,12 +82,17 @@ export const VideoElement: React.FC<VideoElementProps> = ({
onPictureInPictureChange,
onWaiting,
onCanPlay,
onQualityChange,
onBufferStart,
onBufferEnd,
onFirstPlay,
onAudioTracksLoaded,
onQualityLevelsLoaded,
onSubtitleTracksLoaded,
}) => {
const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext()
const lastClickTimeRef = React.useRef<number>(0)
const hasPlayedRef = React.useRef(false)
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
const [availableQualities, setAvailableQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
@@ -93,8 +102,12 @@ export const VideoElement: React.FC<VideoElementProps> = ({
// Handle video events
const handlePlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: true }))
if (!hasPlayedRef.current) {
hasPlayedRef.current = true
onFirstPlay?.()
}
onPlay?.()
}, [setVideoState, onPlay])
}, [setVideoState, onPlay, onFirstPlay])
const handlePause = useCallback(() => {
setVideoState((prev) => ({ ...prev, playing: false }))
@@ -189,13 +202,15 @@ export const VideoElement: React.FC<VideoElementProps> = ({
const handleWaiting = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: true }))
onBufferStart?.()
onWaiting?.()
}, [setVideoState, onWaiting])
}, [setVideoState, onWaiting, onBufferStart])
const handleCanPlay = useCallback(() => {
setVideoState((prev) => ({ ...prev, loading: false }))
onBufferEnd?.()
onCanPlay?.()
}, [setVideoState, onCanPlay])
}, [setVideoState, onCanPlay, onBufferEnd])
const handleProgress = useCallback(() => {
const video = videoRef.current
@@ -687,6 +702,8 @@ export const VideoElement: React.FC<VideoElementProps> = ({
return
}
onQualityChange?.(settings.quality)
let targetLevelIndex =
typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined
+3 -3
View File
@@ -7,8 +7,8 @@
border-radius: var(--player-radius);
overflow: hidden;
color: var(--player-text);
font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
font-family: var(--player-font-family, 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
@@ -26,7 +26,7 @@
.video-player::before {
content: '';
display: block;
padding-top: 56.25%;
padding-top: var(--player-aspect-ratio, 56.25%);
}
.video-player > * {
+371 -233
View File
@@ -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 { VideoElement } from './VideoElement'
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 '../styles/variables.css'
import './VideoPlayer.css'
@@ -26,245 +26,383 @@ const initializePolyfillsIfNeeded = () => {
// Initialize polyfills if needed
initializePolyfillsIfNeeded()
const VideoPlayerContent: React.FC<
VideoPlayerProps & {
audioTracks: AudioTrack[]
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[]
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
hlsSubtitles: SubtitleTrack[]
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
const parseAspectRatio = (ratio: VideoPlayerProps['aspectRatio']): string => {
if (!ratio) return '56.25%'
if (typeof ratio === 'number') return `${ratio * 100}%`
const map: Record<string, string> = {
'16:9': '56.25%',
'4:3': '75%',
'21:9': '42.857%',
'1:1': '100%',
'9:16': '177.778%',
}
> = ({
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 || {}
}
return map[ratio] || '56.25%'
}
const cssVariables: Record<string, string> = {}
if (theme.primaryColor) {
cssVariables['--player-primary'] = theme.primaryColor
}
if (theme.accentColor) {
cssVariables['--player-primary-hover'] = theme.accentColor
}
if (theme.backgroundColor) {
cssVariables['--player-bg'] = theme.backgroundColor
}
if (theme.textColor) {
cssVariables['--player-text'] = theme.textColor
}
interface VideoPlayerContentProps extends VideoPlayerProps {
audioTracks: AudioTrack[]
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[]
onQualityLevelsLoadedInternal: (qualities: VideoQuality[]) => void
hlsSubtitles: SubtitleTrack[]
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
}
return {
...cssVariables,
...(style || {}),
} as React.CSSProperties
}, [theme, style])
const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps>(
(
{
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,
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
const allSubtitles = [...subtitles, ...hlsSubtitles]
// Ref forwarding
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 (
<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}
onAudioTracksLoaded={onAudioTracksLoadedInternal}
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
/>
{controls && (
<ControlsLayer
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
const themedStyle = useMemo<React.CSSProperties>(() => {
const cssVariables: Record<string, string> = {}
// Aspect ratio
if (aspectRatio) {
cssVariables['--player-aspect-ratio'] = parseAspectRatio(aspectRatio)
}
if (theme) {
if (theme.primaryColor) {
cssVariables['--player-primary'] = theme.primaryColor
}
if (theme.accentColor) {
cssVariables['--player-primary-hover'] = theme.accentColor
}
if (theme.backgroundColor) {
cssVariables['--player-bg'] = theme.backgroundColor
}
if (theme.textColor) {
cssVariables['--player-text'] = theme.textColor
}
if (theme.fontFamily) {
cssVariables['--player-font-family'] = theme.fontFamily
}
if (theme.borderRadius !== undefined) {
cssVariables['--player-radius'] =
typeof theme.borderRadius === 'number' ? `${theme.borderRadius}px` : theme.borderRadius
}
if (theme.overlayOpacity !== undefined) {
cssVariables['--player-overlay-soft'] = `rgba(0, 0, 0, ${theme.overlayOpacity})`
}
if (theme.controlsBackground) {
cssVariables['--player-surface'] = theme.controlsBackground
}
if (theme.textSecondaryColor) {
cssVariables['--player-text-secondary'] = theme.textSecondaryColor
}
if (theme.textMutedColor) {
cssVariables['--player-text-muted'] = theme.textMutedColor
}
}
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}
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}
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
qualities={qualities}
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
hlsSubtitles={hlsSubtitles}
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
/>
)}
</div>
)
}
</PlayerProvider>
)
}
)
export const VideoPlayer: React.FC<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,
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>
)
}
VideoPlayer.displayName = 'VideoPlayer'
+3 -1
View File
@@ -8,6 +8,7 @@ interface SettingsMenuProps {
subtitles?: Array<{ src: string; lang: string; label: string }>
audioTracks?: AudioTrack[]
qualities?: VideoQuality[]
playbackRates?: number[]
}
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio' | 'quality'
@@ -16,6 +17,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
subtitles = [],
audioTracks = [],
qualities = [],
playbackRates: playbackRatesProp,
}) => {
const {
uiState,
@@ -31,7 +33,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
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]
const playbackRates = playbackRatesProp ?? [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
// Close menu when clicking outside
useEffect(() => {
+7 -2
View File
@@ -29,6 +29,7 @@ interface PlayerProviderProps {
initialMuted?: boolean
initialPlaybackRate?: number
language?: string
customTranslations?: Partial<Translations>
}
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
@@ -37,12 +38,16 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
initialMuted = false,
initialPlaybackRate = 1,
language,
customTranslations,
}) => {
const videoRef = useRef<HTMLVideoElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
// Get translations based on language prop or browser language
const translations = getTranslations(language || detectBrowserLanguage())
// Get translations based on language prop or browser language, merged with custom translations
const baseTranslations = getTranslations(language || detectBrowserLanguage())
const translations = customTranslations
? { ...baseTranslations, ...customTranslations }
: baseTranslations
const [videoState, setVideoState] = useState<VideoState>({
playing: false,
+33 -9
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
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 {
videoState,
containerRef,
@@ -70,6 +71,13 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
useEffect(() => {
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 container = containerRef?.current
if (container && !isActivePlayer) {
@@ -85,65 +93,79 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
return
}
switch (e.key.toLowerCase()) {
const key = e.key.toLowerCase()
switch (key) {
case ' ':
case 'k':
if (isDisabled('space') || isDisabled('k')) break
e.preventDefault()
togglePlay()
break
case 'arrowleft':
if (isDisabled('arrowleft')) break
e.preventDefault()
seek(Math.max(0, videoState.currentTime - 5))
seek(Math.max(0, videoState.currentTime - seekSmall))
break
case 'arrowright':
if (isDisabled('arrowright')) break
e.preventDefault()
seek(Math.min(videoState.duration, videoState.currentTime + 5))
seek(Math.min(videoState.duration, videoState.currentTime + seekSmall))
break
case 'j':
if (isDisabled('j')) break
e.preventDefault()
seek(Math.max(0, videoState.currentTime - 10))
seek(Math.max(0, videoState.currentTime - seekLarge))
break
case 'l':
if (isDisabled('l')) break
e.preventDefault()
seek(Math.min(videoState.duration, videoState.currentTime + 10))
seek(Math.min(videoState.duration, videoState.currentTime + seekLarge))
break
case 'arrowup':
if (isDisabled('arrowup')) break
e.preventDefault()
setVolume(Math.min(1, videoState.volume + 0.1))
setVolume(Math.min(1, videoState.volume + volumeStep))
break
case 'arrowdown':
if (isDisabled('arrowdown')) break
e.preventDefault()
setVolume(Math.max(0, videoState.volume - 0.1))
setVolume(Math.max(0, videoState.volume - volumeStep))
break
case 'm':
if (isDisabled('m')) break
e.preventDefault()
toggleMute()
break
case 'f':
if (isDisabled('f')) break
e.preventDefault()
toggleFullscreen()
break
case 'p':
if (isDisabled('p')) break
e.preventDefault()
togglePictureInPicture()
break
case '0':
case 'home':
if (isDisabled('0') || isDisabled('home')) break
e.preventDefault()
seek(0)
break
case 'end':
if (isDisabled('end')) break
e.preventDefault()
seek(videoState.duration)
break
@@ -157,8 +179,9 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
case '7':
case '8':
case '9': {
if (isDisabled(key)) break
e.preventDefault()
const percent = parseInt(e.key, 10) / 10
const percent = parseInt(key, 10) / 10
seek(videoState.duration * percent)
break
}
@@ -183,5 +206,6 @@ export const useKeyboardShortcuts = (enabled: boolean = true) => {
toggleMute,
toggleFullscreen,
togglePictureInPicture,
config,
])
}
+12 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, MutableRefObject } from 'react'
import { usePlayerContext } from '../contexts/PlayerContext'
import type { TouchConfig } from '../types'
interface TouchData {
startX: number
@@ -9,13 +10,17 @@ interface TouchData {
tapCount: number
}
export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement | null>) => {
export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement | null>, touchConfig?: TouchConfig) => {
const { videoState, togglePlay, seek, setVolume } = usePlayerContext()
useEffect(() => {
const container = containerRef.current
if (!container) return
const maxSeekSeconds = touchConfig?.maxSeekSeconds ?? 30
const maxVolumeChange = touchConfig?.maxVolumeChange ?? 0.5
const doubleTapSeekSeconds = touchConfig?.doubleTapSeekSeconds ?? 10
const touchData: TouchData = {
startX: 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) > Math.abs(deltaY)) {
// 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)))
} else {
// 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)))
}
}
@@ -86,14 +91,11 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
const isLeftSide = relativeX < rect.width / 2
if (isLeftSide) {
// Double tap left - rewind 10 seconds
seek(Math.max(0, videoState.currentTime - 10))
seek(Math.max(0, videoState.currentTime - doubleTapSeekSeconds))
} else {
// Double tap right - forward 10 seconds
seek(Math.min(videoState.duration, videoState.currentTime + 10))
seek(Math.min(videoState.duration, videoState.currentTime + doubleTapSeekSeconds))
}
// Show feedback animation (optional - can be implemented later)
showDoubleTapFeedback(isLeftSide)
}
@@ -108,7 +110,7 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
feedback.style.fontSize = '48px'
feedback.style.pointerEvents = 'none'
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
feedback.textContent = isLeft ? '« 10s' : '10s »'
feedback.textContent = isLeft ? `« ${doubleTapSeekSeconds}s` : `${doubleTapSeekSeconds}s »`
container?.appendChild(feedback)
setTimeout(() => feedback.remove(), 500)
@@ -121,5 +123,5 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
container.removeEventListener('touchstart', handleTouchStart)
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])
}
+3
View File
@@ -7,10 +7,13 @@ export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
// Types
export type {
VideoPlayerProps,
VideoPlayerHandle,
SubtitleTrack,
AudioTrack,
VideoQuality,
PlayerTheme,
KeyboardShortcutConfig,
TouchConfig,
VideoState,
UIState,
PlayerSettings,
+10
View File
@@ -67,3 +67,13 @@
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
View File
@@ -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'
@@ -32,6 +33,47 @@ export interface PlayerTheme {
accentColor?: string
backgroundColor?: 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 {
@@ -41,9 +83,9 @@ export interface VideoPlayerProps {
autoplay?: boolean
loop?: boolean
muted?: boolean
volume?: number // 0-1 arası ses seviyesi
playbackRate?: number // Oynatma hızı (0.25, 0.5, 1, 1.5, 2, vb.)
currentTime?: number // Başlangıç zamanı (saniye)
volume?: number
playbackRate?: number
currentTime?: number
crossOrigin?: '' | 'anonymous' | 'use-credentials'
preload?: 'none' | 'metadata' | 'auto'
playsInline?: boolean
@@ -56,6 +98,29 @@ export interface VideoPlayerProps {
pictureInPicture?: boolean
className?: string
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
onPlay?: () => void
onPause?: () => void
@@ -73,6 +138,12 @@ export interface VideoPlayerProps {
onPictureInPictureChange?: (isPictureInPicture: boolean) => void
onWaiting?: () => void
onCanPlay?: () => void
// Ek analytics event'leri
onQualityChange?: (quality: VideoQuality) => void
onBufferStart?: () => void
onBufferEnd?: () => void
onFirstPlay?: () => void
}
export interface VideoState {
+7
View File
@@ -123,11 +123,14 @@ export const initializePolyfills = (): PolyfillDiagnostics => {
/**
* Feature detection utilities
*/
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
export const features = {
/**
* Check if browser supports HLS natively
*/
hasNativeHLS: (): boolean => {
if (!isBrowser) return false
const video = document.createElement('video')
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
},
@@ -143,6 +146,7 @@ export const features = {
* Check if Picture-in-Picture is truly supported
*/
hasPIP: (): boolean => {
if (!isBrowser) return false
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
},
@@ -150,6 +154,7 @@ export const features = {
* Check if Fullscreen API is supported
*/
hasFullscreen: (): boolean => {
if (!isBrowser) return false
return !!(
document.fullscreenEnabled ||
// @ts-ignore
@@ -165,6 +170,7 @@ export const features = {
* Check if touch events are supported (mobile device)
*/
hasTouch: (): boolean => {
if (!isBrowser) return false
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
},
@@ -172,6 +178,7 @@ export const features = {
* Detect iOS Safari
*/
isIOSSafari: (): boolean => {
if (!isBrowser) return false
const ua = navigator.userAgent
const iOS = /iPad|iPhone|iPod/.test(ua)
const webkit = /WebKit/.test(ua)