bad1cc6ca0
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.
241 lines
7.4 KiB
TypeScript
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>
|
|
)
|
|
}
|