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 { 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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 > * {
|
||||
|
||||
+152
-14
@@ -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,8 +26,20 @@ const initializePolyfillsIfNeeded = () => {
|
||||
// Initialize polyfills if needed
|
||||
initializePolyfillsIfNeeded()
|
||||
|
||||
const VideoPlayerContent: React.FC<
|
||||
VideoPlayerProps & {
|
||||
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%',
|
||||
}
|
||||
return map[ratio] || '56.25%'
|
||||
}
|
||||
|
||||
interface VideoPlayerContentProps extends VideoPlayerProps {
|
||||
audioTracks: AudioTrack[]
|
||||
onAudioTracksLoadedInternal: (tracks: AudioTrack[]) => void
|
||||
qualities: VideoQuality[]
|
||||
@@ -35,7 +47,10 @@ const VideoPlayerContent: React.FC<
|
||||
hlsSubtitles: SubtitleTrack[]
|
||||
onSubtitleTracksLoadedInternal: (tracks: SubtitleTrack[]) => void
|
||||
}
|
||||
> = ({
|
||||
|
||||
const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps>(
|
||||
(
|
||||
{
|
||||
src,
|
||||
poster,
|
||||
protocol = 'auto',
|
||||
@@ -56,6 +71,14 @@ const VideoPlayerContent: React.FC<
|
||||
pictureInPicture = true,
|
||||
className = '',
|
||||
style,
|
||||
controlsAutoHideDelay = 3000,
|
||||
playbackRates,
|
||||
aspectRatio,
|
||||
keyboardShortcutConfig,
|
||||
touchConfig,
|
||||
children,
|
||||
controlsLeftExtra,
|
||||
controlsRightExtra,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
@@ -72,21 +95,61 @@ const VideoPlayerContent: React.FC<
|
||||
onPictureInPictureChange,
|
||||
onWaiting,
|
||||
onCanPlay,
|
||||
onQualityChange,
|
||||
onBufferStart,
|
||||
onBufferEnd,
|
||||
onFirstPlay,
|
||||
audioTracks,
|
||||
onAudioTracksLoadedInternal,
|
||||
qualities,
|
||||
onQualityLevelsLoadedInternal,
|
||||
hlsSubtitles,
|
||||
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 themedStyle = useMemo<React.CSSProperties>(() => {
|
||||
if (!theme) {
|
||||
return style || {}
|
||||
const cssVariables: Record<string, string> = {}
|
||||
|
||||
// Aspect ratio
|
||||
if (aspectRatio) {
|
||||
cssVariables['--player-aspect-ratio'] = parseAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
const cssVariables: Record<string, string> = {}
|
||||
if (theme) {
|
||||
if (theme.primaryColor) {
|
||||
cssVariables['--player-primary'] = theme.primaryColor
|
||||
}
|
||||
@@ -99,12 +162,36 @@ const VideoPlayerContent: React.FC<
|
||||
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])
|
||||
}, [theme, style, aspectRatio])
|
||||
|
||||
// Merge manual subtitles and HLS-detected subtitles
|
||||
const allSubtitles = [...subtitles, ...hlsSubtitles]
|
||||
@@ -147,6 +234,10 @@ const VideoPlayerContent: React.FC<
|
||||
onPictureInPictureChange={onPictureInPictureChange}
|
||||
onWaiting={onWaiting}
|
||||
onCanPlay={onCanPlay}
|
||||
onQualityChange={onQualityChange}
|
||||
onBufferStart={onBufferStart}
|
||||
onBufferEnd={onBufferEnd}
|
||||
onFirstPlay={onFirstPlay}
|
||||
onAudioTracksLoaded={onAudioTracksLoadedInternal}
|
||||
onQualityLevelsLoaded={onQualityLevelsLoadedInternal}
|
||||
onSubtitleTracksLoaded={onSubtitleTracksLoadedInternal}
|
||||
@@ -154,17 +245,33 @@ const VideoPlayerContent: React.FC<
|
||||
{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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
VideoPlayerContent.displayName = 'VideoPlayerContent'
|
||||
|
||||
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
(
|
||||
{
|
||||
src,
|
||||
poster,
|
||||
protocol = 'auto',
|
||||
@@ -186,6 +293,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
pictureInPicture = true,
|
||||
className = '',
|
||||
style,
|
||||
controlsAutoHideDelay,
|
||||
playbackRates,
|
||||
aspectRatio,
|
||||
keyboardShortcutConfig,
|
||||
touchConfig,
|
||||
translations: customTranslations,
|
||||
children,
|
||||
controlsLeftExtra,
|
||||
controlsRightExtra,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
@@ -202,7 +318,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onPictureInPictureChange,
|
||||
onWaiting,
|
||||
onCanPlay,
|
||||
}) => {
|
||||
onQualityChange,
|
||||
onBufferStart,
|
||||
onBufferEnd,
|
||||
onFirstPlay,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||
const [qualities, setQualities] = useState<VideoQuality[]>([])
|
||||
const [hlsSubtitles, setHlsSubtitles] = useState<SubtitleTrack[]>([])
|
||||
@@ -220,8 +342,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlayerProvider initialMuted={muted} language={language}>
|
||||
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
||||
<VideoPlayerContent
|
||||
ref={ref}
|
||||
src={src}
|
||||
poster={poster}
|
||||
protocol={protocol}
|
||||
@@ -242,6 +365,14 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
pictureInPicture={pictureInPicture}
|
||||
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}
|
||||
@@ -258,6 +389,10 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onPictureInPictureChange={onPictureInPictureChange}
|
||||
onWaiting={onWaiting}
|
||||
onCanPlay={onCanPlay}
|
||||
onQualityChange={onQualityChange}
|
||||
onBufferStart={onBufferStart}
|
||||
onBufferEnd={onBufferEnd}
|
||||
onFirstPlay={onFirstPlay}
|
||||
audioTracks={audioTracks}
|
||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||
qualities={qualities}
|
||||
@@ -268,3 +403,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
</PlayerProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VideoPlayer.displayName = 'VideoPlayer'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
||||
// Types
|
||||
export type {
|
||||
VideoPlayerProps,
|
||||
VideoPlayerHandle,
|
||||
SubtitleTrack,
|
||||
AudioTrack,
|
||||
VideoQuality,
|
||||
PlayerTheme,
|
||||
KeyboardShortcutConfig,
|
||||
TouchConfig,
|
||||
VideoState,
|
||||
UIState,
|
||||
PlayerSettings,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user