From 58a405d895ae9ec98dbb549ac1cf3abb6f1d282c Mon Sep 17 00:00:00 2001 From: hibna Date: Thu, 12 Feb 2026 19:23:54 +0300 Subject: [PATCH] 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 --- src/components/ControlsLayer.tsx | 36 +- src/components/VideoElement.tsx | 23 +- src/components/VideoPlayer.css | 6 +- src/components/VideoPlayer.tsx | 604 ++++++++++++++++---------- src/components/menus/SettingsMenu.tsx | 4 +- src/contexts/PlayerContext.tsx | 9 +- src/hooks/useKeyboardShortcuts.ts | 42 +- src/hooks/useTouchGestures.ts | 22 +- src/index.ts | 3 + src/styles/variables.css | 10 + src/types/index.ts | 79 +++- src/utils/polyfills.ts | 7 + 12 files changed, 572 insertions(+), 273 deletions(-) diff --git a/src/components/ControlsLayer.tsx b/src/components/ControlsLayer.tsx index 1f357e3..94b4146 100644 --- a/src/components/ControlsLayer.tsx +++ b/src/components/ControlsLayer.tsx @@ -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 = ({ 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 = ({ 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 = ({ }, [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 = ({
- + {features.hasVolumeControl() && } {/* Time display - hidden for live broadcasts */} {!videoState.isLiveBroadcast && } {/* Show "LIVE" badge for live broadcasts */} @@ -231,13 +244,20 @@ export const ControlsLayer: React.FC = ({ {translations.live}
)} + {controlsLeftExtra}
+ {controlsRightExtra}
- +
{pictureInPicture && } diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx index 09890e0..096ff5d 100644 --- a/src/components/VideoElement.tsx +++ b/src/components/VideoElement.tsx @@ -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 = ({ onPictureInPictureChange, onWaiting, onCanPlay, + onQualityChange, + onBufferStart, + onBufferEnd, + onFirstPlay, onAudioTracksLoaded, onQualityLevelsLoaded, onSubtitleTracksLoaded, }) => { const { videoRef, setVideoState, toggleFullscreen, settings, setQuality, setSubtitle } = usePlayerContext() const lastClickTimeRef = React.useRef(0) + const hasPlayedRef = React.useRef(false) const [availableAudioTracks, setAvailableAudioTracks] = useState([]) const [availableQualities, setAvailableQualities] = useState([]) const [hlsSubtitles, setHlsSubtitles] = useState([]) @@ -93,8 +102,12 @@ export const VideoElement: React.FC = ({ // 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 = ({ 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 = ({ return } + onQualityChange?.(settings.quality) + let targetLevelIndex = typeof settings.quality.levelIndex === 'number' ? settings.quality.levelIndex : undefined diff --git a/src/components/VideoPlayer.css b/src/components/VideoPlayer.css index c0511ab..eab22a9 100644 --- a/src/components/VideoPlayer.css +++ b/src/components/VideoPlayer.css @@ -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 > * { diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 03f97b3..c6c98b9 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -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 = { + '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(() => { - if (!theme) { - return style || {} - } + return map[ratio] || '56.25%' +} - const cssVariables: Record = {} - 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( + ( + { + 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 ( -
- - {controls && ( - (() => { + const cssVariables: Record = {} + + // 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 ( +
+ + {controls && ( + + )} + {children && ( +
+
{children}
+
+ )} +
+ ) + } +) + +VideoPlayerContent.displayName = 'VideoPlayerContent' + +export const VideoPlayer = forwardRef( + ( + { + 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([]) + const [qualities, setQualities] = useState([]) + const [hlsSubtitles, setHlsSubtitles] = useState([]) + + const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => { + setAudioTracks(tracks) + }, []) + + const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => { + setQualities(levels) + }, []) + + const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => { + setHlsSubtitles(tracks) + }, []) + + return ( + + - )} -
- ) -} + + ) + } +) -export const VideoPlayer: React.FC = ({ - 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([]) - const [qualities, setQualities] = useState([]) - const [hlsSubtitles, setHlsSubtitles] = useState([]) - - const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => { - setAudioTracks(tracks) - }, []) - - const handleQualityLevelsLoaded = useCallback((levels: VideoQuality[]) => { - setQualities(levels) - }, []) - - const handleSubtitleTracksLoaded = useCallback((tracks: SubtitleTrack[]) => { - setHlsSubtitles(tracks) - }, []) - - return ( - - - - ) -} +VideoPlayer.displayName = 'VideoPlayer' diff --git a/src/components/menus/SettingsMenu.tsx b/src/components/menus/SettingsMenu.tsx index aacda84..0c93ce0 100644 --- a/src/components/menus/SettingsMenu.tsx +++ b/src/components/menus/SettingsMenu.tsx @@ -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 = ({ subtitles = [], audioTracks = [], qualities = [], + playbackRates: playbackRatesProp, }) => { const { uiState, @@ -31,7 +33,7 @@ export const SettingsMenu: React.FC = ({ const menuRef = useRef(null) const [currentView, setCurrentView] = useState('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(() => { diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx index 2779394..3560d18 100644 --- a/src/contexts/PlayerContext.tsx +++ b/src/contexts/PlayerContext.tsx @@ -29,6 +29,7 @@ interface PlayerProviderProps { initialMuted?: boolean initialPlaybackRate?: number language?: string + customTranslations?: Partial } export const PlayerProvider: React.FC = ({ @@ -37,12 +38,16 @@ export const PlayerProvider: React.FC = ({ initialMuted = false, initialPlaybackRate = 1, language, + customTranslations, }) => { const videoRef = useRef(null) const containerRef = useRef(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({ playing: false, diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 1e6c2ad..a193188 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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, ]) } diff --git a/src/hooks/useTouchGestures.ts b/src/hooks/useTouchGestures.ts index 0735b85..cd1ecd9 100644 --- a/src/hooks/useTouchGestures.ts +++ b/src/hooks/useTouchGestures.ts @@ -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) => { +export const useTouchGestures = (containerRef: MutableRefObject, 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 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 feedback.remove(), 500) @@ -121,5 +123,5 @@ export const useTouchGestures = (containerRef: MutableRefObject 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 + + // 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 { diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts index 327e6b9..4bb1baf 100644 --- a/src/utils/polyfills.ts +++ b/src/utils/polyfills.ts @@ -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)