58a405d895
- 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>
271 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|