Files
player/src/components/ControlsLayer.tsx
T
hibna 58a405d895 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>
2026-02-12 19:23:54 +03:00

271 lines
8.6 KiB
TypeScript

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'
import { VolumeControl } from './controls/VolumeControl'
import { TimeDisplay } from './controls/TimeDisplay'
import { FullscreenButton } from './controls/FullscreenButton'
import { PIPButton } from './controls/PIPButton'
import { SettingsButton } from './controls/SettingsButton'
import { LoadingSpinner } from './overlays/LoadingSpinner'
import { CenterPlayButton } from './controls/CenterPlayButton'
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
import { useTouchGestures } from '../hooks/useTouchGestures'
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()
const [isPointerOver, setIsPointerOver] = useState(false)
const [lastInteraction, setLastInteraction] = useState<number>(0)
const hideTimeoutRef = useRef<number | undefined>(undefined)
const containerRef = useRef<HTMLDivElement | null>(null)
const lastClickTimeRef = useRef<number>(0)
const isMenuOpen =
uiState.settingsOpen ||
uiState.volumeControlOpen ||
uiState.qualityMenuOpen ||
uiState.subtitleMenuOpen
const autoHideEnabled = videoState.fullscreen && videoState.playing && !isMenuOpen
const clearHideTimeout = useCallback(() => {
if (hideTimeoutRef.current) {
window.clearTimeout(hideTimeoutRef.current)
hideTimeoutRef.current = undefined
}
}, [])
const scheduleHide = useCallback(() => {
if (!autoHideEnabled) {
clearHideTimeout()
return
}
clearHideTimeout()
hideTimeoutRef.current = window.setTimeout(() => {
hideControls()
}, controlsAutoHideDelay)
}, [autoHideEnabled, clearHideTimeout, hideControls, controlsAutoHideDelay])
// Keep controls visible when not playing or when any menu is open
useEffect(() => {
if (!videoState.playing || isMenuOpen) {
clearHideTimeout()
showControls()
}
}, [videoState.playing, isMenuOpen, showControls, clearHideTimeout])
// Manage controls visibility when leaving fullscreen
useEffect(() => {
if (!videoState.fullscreen) {
clearHideTimeout()
if (!videoState.playing || isPointerOver) {
showControls()
}
}
}, [videoState.fullscreen, videoState.playing, isPointerOver, showControls, clearHideTimeout])
// Re-schedule auto hide when interaction changes
useEffect(() => {
if (autoHideEnabled && lastInteraction > 0) {
scheduleHide()
}
return () => {
if (autoHideEnabled) {
clearHideTimeout()
}
}
}, [autoHideEnabled, lastInteraction, scheduleHide, clearHideTimeout])
const handleMouseEnter = useCallback(() => {
setIsPointerOver(true)
showControls()
if (autoHideEnabled) {
setLastInteraction(Date.now())
}
}, [autoHideEnabled, showControls])
// Handle mouse movement
const handleMouseMove = useCallback(() => {
setIsPointerOver(true)
showControls()
if (autoHideEnabled) {
setLastInteraction(Date.now())
}
}, [autoHideEnabled, showControls])
const handleMouseLeave = useCallback(() => {
setIsPointerOver(false)
clearHideTimeout()
if (videoState.fullscreen) {
if (videoState.playing) {
hideControls()
} else {
showControls()
}
} else if (videoState.playing) {
hideControls()
}
}, [clearHideTimeout, videoState.fullscreen, videoState.playing, hideControls, showControls])
useEffect(() => {
return () => {
clearHideTimeout()
}
}, [clearHideTimeout])
const previousAutoHide = useRef(autoHideEnabled)
useEffect(() => {
if (autoHideEnabled && !previousAutoHide.current) {
showControls()
setLastInteraction(Date.now())
}
previousAutoHide.current = autoHideEnabled
}, [autoHideEnabled, showControls])
// Keyboard shortcuts
useKeyboardShortcuts(keyboardShortcuts, keyboardShortcutConfig)
// Touch gestures
useTouchGestures(containerRef, touchConfig)
// Handle click for play/pause and double-click for fullscreen
const handleClick = useCallback(
(e: React.MouseEvent) => {
// Get the actual element that was clicked
const target = e.target as HTMLElement
const currentTarget = e.currentTarget as HTMLElement
// Allow clicks on:
// 1. The controls layer itself (when controls are hidden, pointer-events: none makes it work)
// 2. The center play overlay
// Don't handle clicks on control buttons or other interactive elements
const isClickableArea =
target === currentTarget ||
target.classList.contains('center-play-overlay') ||
target.classList.contains('controls-layer')
if (!isClickableArea) {
return
}
const now = Date.now()
const timeSinceLastClick = now - lastClickTimeRef.current
if (timeSinceLastClick < 300) {
// Double click - toggle fullscreen
e.preventDefault() // Prevent text selection on double click
toggleFullscreen()
lastClickTimeRef.current = 0
} else {
// Single click - toggle play/pause (with delay to detect double click)
setTimeout(() => {
if (Date.now() - lastClickTimeRef.current >= 300) {
togglePlay()
}
}, 300)
lastClickTimeRef.current = now
}
},
[togglePlay, toggleFullscreen]
)
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
videoState.playing ? 'playing' : 'paused'
} ${videoState.fullscreen ? 'fullscreen' : 'windowed'}`
return (
<div
ref={containerRef}
className={controlsClassName}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{/* Loading spinner */}
{videoState.loading && <LoadingSpinner />}
{/* Center play button (only when paused) */}
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
{/* Bottom controls bar */}
<div className="controls-bar">
{/* Progress bar (full width on top) - hidden for live broadcasts */}
{!videoState.isLiveBroadcast && (
<div className="progress-container">
<ProgressBar />
</div>
)}
{/* Control buttons */}
<div className="controls-row">
<div className="controls-left">
<PlayPauseButton />
{features.hasVolumeControl() && <VolumeControl />}
{/* Time display - hidden for live broadcasts */}
{!videoState.isLiveBroadcast && <TimeDisplay />}
{/* Show "LIVE" badge for live broadcasts */}
{videoState.isLiveBroadcast && (
<div className="live-indicator">
<span className="live-dot"></span>
<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}
playbackRates={playbackRates}
/>
</Suspense>
</div>
{pictureInPicture && <PIPButton />}
<FullscreenButton />
</div>
</div>
</div>
</div>
)
}