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 { 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 />}
+20 -3
View File
@@ -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
+3 -3
View File
@@ -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 > * {
+152 -14
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 { 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,8 +26,20 @@ 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%'
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%',
}
return map[ratio] || '56.25%'
}
interface VideoPlayerContentProps extends VideoPlayerProps {
audioTracks: AudioTrack[] audioTracks: AudioTrack[]
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
qualities: VideoQuality[] qualities: VideoQuality[]
@@ -35,7 +47,10 @@ const VideoPlayerContent: React.FC<
hlsSubtitles: SubtitleTrack[] hlsSubtitles: SubtitleTrack[]
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
} }
> = ({
const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps>(
(
{
src, src,
poster, poster,
protocol = 'auto', protocol = 'auto',
@@ -56,6 +71,14 @@ const VideoPlayerContent: React.FC<
pictureInPicture = true, pictureInPicture = true,
className = '', className = '',
style, style,
controlsAutoHideDelay = 3000,
playbackRates,
aspectRatio,
keyboardShortcutConfig,
touchConfig,
children,
controlsLeftExtra,
controlsRightExtra,
onPlay, onPlay,
onPause, onPause,
onEnded, onEnded,
@@ -72,21 +95,61 @@ const VideoPlayerContent: React.FC<
onPictureInPictureChange, onPictureInPictureChange,
onWaiting, onWaiting,
onCanPlay, onCanPlay,
onQualityChange,
onBufferStart,
onBufferEnd,
onFirstPlay,
audioTracks, audioTracks,
onAudioTracksLoadedInternal, onAudioTracksLoadedInternal,
qualities, qualities,
onQualityLevelsLoadedInternal, onQualityLevelsLoadedInternal,
hlsSubtitles, hlsSubtitles,
onSubtitleTracksLoadedInternal, onSubtitleTracksLoadedInternal,
}) => { },
const { containerRef, uiState } = usePlayerContext() ref
) => {
const {
containerRef,
uiState,
videoRef,
play,
pause,
seek,
setVolume,
toggleMute,
toggleFullscreen,
togglePictureInPicture,
setPlaybackRate,
} = usePlayerContext()
// 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]
)
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : '' const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
const themedStyle = useMemo<React.CSSProperties>(() => { const themedStyle = useMemo<React.CSSProperties>(() => {
if (!theme) { const cssVariables: Record<string, string> = {}
return style || {}
// Aspect ratio
if (aspectRatio) {
cssVariables['--player-aspect-ratio'] = parseAspectRatio(aspectRatio)
} }
const cssVariables: Record<string, string> = {} if (theme) {
if (theme.primaryColor) { if (theme.primaryColor) {
cssVariables['--player-primary'] = theme.primaryColor cssVariables['--player-primary'] = theme.primaryColor
} }
@@ -99,12 +162,36 @@ const VideoPlayerContent: React.FC<
if (theme.textColor) { if (theme.textColor) {
cssVariables['--player-text'] = 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 { return {
...cssVariables, ...cssVariables,
...(style || {}), ...(style || {}),
} as React.CSSProperties } as React.CSSProperties
}, [theme, style]) }, [theme, style, aspectRatio])
// Merge manual subtitles and HLS-detected subtitles // Merge manual subtitles and HLS-detected subtitles
const allSubtitles = [...subtitles, ...hlsSubtitles] const allSubtitles = [...subtitles, ...hlsSubtitles]
@@ -147,6 +234,10 @@ const VideoPlayerContent: React.FC<
onPictureInPictureChange={onPictureInPictureChange} onPictureInPictureChange={onPictureInPictureChange}
onWaiting={onWaiting} onWaiting={onWaiting}
onCanPlay={onCanPlay} onCanPlay={onCanPlay}
onQualityChange={onQualityChange}
onBufferStart={onBufferStart}
onBufferEnd={onBufferEnd}
onFirstPlay={onFirstPlay}
onAudioTracksLoaded={onAudioTracksLoadedInternal} onAudioTracksLoaded={onAudioTracksLoadedInternal}
onQualityLevelsLoaded={onQualityLevelsLoadedInternal} onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal} onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
@@ -154,17 +245,33 @@ const VideoPlayerContent: React.FC<
{controls && ( {controls && (
<ControlsLayer <ControlsLayer
keyboardShortcuts={keyboardShortcuts} keyboardShortcuts={keyboardShortcuts}
keyboardShortcutConfig={keyboardShortcutConfig}
pictureInPicture={pictureInPicture} pictureInPicture={pictureInPicture}
subtitles={allSubtitles} subtitles={allSubtitles}
audioTracks={audioTracks} audioTracks={audioTracks}
qualities={qualities} 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> </div>
) )
} }
)
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ VideoPlayerContent.displayName = 'VideoPlayerContent'
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
(
{
src, src,
poster, poster,
protocol = 'auto', protocol = 'auto',
@@ -186,6 +293,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
pictureInPicture = true, pictureInPicture = true,
className = '', className = '',
style, style,
controlsAutoHideDelay,
playbackRates,
aspectRatio,
keyboardShortcutConfig,
touchConfig,
translations: customTranslations,
children,
controlsLeftExtra,
controlsRightExtra,
onPlay, onPlay,
onPause, onPause,
onEnded, onEnded,
@@ -202,7 +318,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onPictureInPictureChange, onPictureInPictureChange,
onWaiting, onWaiting,
onCanPlay, onCanPlay,
}) => { onQualityChange,
onBufferStart,
onBufferEnd,
onFirstPlay,
},
ref
) => {
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]) const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
const [qualities, setQualities] = useState<VideoQuality[]>([]) const [qualities, setQualities] = useState<VideoQuality[]>([])
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([]) const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
@@ -220,8 +342,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
}, []) }, [])
return ( return (
<PlayerProvider initialMuted={muted} language={language}> <PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
<VideoPlayerContent <VideoPlayerContent
ref={ref}
src={src} src={src}
poster={poster} poster={poster}
protocol={protocol} protocol={protocol}
@@ -242,6 +365,14 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
pictureInPicture={pictureInPicture} pictureInPicture={pictureInPicture}
className={className} className={className}
style={style} style={style}
controlsAutoHideDelay={controlsAutoHideDelay}
playbackRates={playbackRates}
aspectRatio={aspectRatio}
keyboardShortcutConfig={keyboardShortcutConfig}
touchConfig={touchConfig}
children={children}
controlsLeftExtra={controlsLeftExtra}
controlsRightExtra={controlsRightExtra}
onPlay={onPlay} onPlay={onPlay}
onPause={onPause} onPause={onPause}
onEnded={onEnded} onEnded={onEnded}
@@ -258,6 +389,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onPictureInPictureChange={onPictureInPictureChange} onPictureInPictureChange={onPictureInPictureChange}
onWaiting={onWaiting} onWaiting={onWaiting}
onCanPlay={onCanPlay} onCanPlay={onCanPlay}
onQualityChange={onQualityChange}
onBufferStart={onBufferStart}
onBufferEnd={onBufferEnd}
onFirstPlay={onFirstPlay}
audioTracks={audioTracks} audioTracks={audioTracks}
onAudioTracksLoadedInternal={handleAudioTracksLoaded} onAudioTracksLoadedInternal={handleAudioTracksLoaded}
qualities={qualities} qualities={qualities}
@@ -268,3 +403,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</PlayerProvider> </PlayerProvider>
) )
} }
)
VideoPlayer.displayName = 'VideoPlayer'
+3 -1
View File
@@ -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(() => {
+7 -2
View File
@@ -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,
+33 -9
View File
@@ -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,
]) ])
} }
+12 -10
View File
@@ -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])
} }
+3
View File
@@ -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,
+10
View File
@@ -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
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' 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 {
+7
View File
@@ -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)