Files
player/src/components/ControlsLayer.tsx
T
hibna bad1cc6ca0 Add i18n, tests, and update documentation
Introduces internationalization (i18n) support with English and Turkish, adds unit tests and test setup with Vitest and React Testing Library, and updates documentation including README and changelog. Removes legacy publishing and usage guides, refactors components to use translation system, and updates build and test scripts in package.json. Also adds new utility modules for HLS and CORS, and improves PlayerContext and SettingsMenu for language support.
2025-10-29 13:10:07 +03:00

241 lines
7.4 KiB
TypeScript

import React, { useEffect, useRef, useState, useCallback, lazy, Suspense } 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 type { SubtitleTrack, AudioTrack, VideoQuality } from '../types'
import './ControlsLayer.css'
const SettingsMenu = lazy(() => import('./menus/SettingsMenu').then(module => ({ default: module.SettingsMenu })))
interface ControlsLayerProps {
keyboardShortcuts?: boolean
pictureInPicture?: boolean
subtitles?: SubtitleTrack[]
audioTracks?: AudioTrack[]
qualities?: VideoQuality[]
}
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
keyboardShortcuts = true,
pictureInPicture = true,
subtitles = [],
audioTracks = [],
qualities = [],
}) => {
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } =
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()
}, 3000)
}, [autoHideEnabled, clearHideTimeout, hideControls])
// 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)
// Touch gestures
useTouchGestures(containerRef)
// 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) */}
<div className="progress-container">
<ProgressBar />
</div>
{/* Control buttons */}
<div className="controls-row">
<div className="controls-left">
<PlayPauseButton />
<VolumeControl />
<TimeDisplay />
</div>
<div className="controls-right">
<div style={{ position: 'relative' }}>
<SettingsButton />
<Suspense fallback={null}>
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} qualities={qualities} />
</Suspense>
</div>
{pictureInPicture && <PIPButton />}
<FullscreenButton />
</div>
</div>
</div>
</div>
)
}