refactor: breakdown VideoControls
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
import { Box, IconButton, Stack, Tooltip, useMediaQuery } from '@mui/material';
|
||||||
|
import { Pause, PlayArrow } from '@mui/icons-material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '@mui/material';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
import ProgressBar from './ProgressBar';
|
||||||
|
import VolumeControl from './VolumeControl';
|
||||||
|
import SubtitleControl from './SubtitleControl';
|
||||||
|
import FullscreenControl from './FullscreenControl';
|
||||||
|
import LoopControl from './LoopControl';
|
||||||
|
import PlaybackControls from './PlaybackControls';
|
||||||
|
|
||||||
|
interface ControlsOverlayProps {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
controlsVisible: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
isDragging: boolean;
|
||||||
|
volume: number;
|
||||||
|
showVolumeSlider: boolean;
|
||||||
|
volumeSliderRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
subtitles: Array<{ language: string; filename: string; path: string }>;
|
||||||
|
subtitlesEnabled: boolean;
|
||||||
|
isLooping: boolean;
|
||||||
|
subtitleMenuAnchor: HTMLElement | null;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
onProgressChange: (value: number) => void;
|
||||||
|
onProgressChangeCommitted: (value: number) => void;
|
||||||
|
onProgressMouseDown: () => void;
|
||||||
|
onVolumeChange: (value: number) => void;
|
||||||
|
onVolumeClick: () => void;
|
||||||
|
onVolumeMouseEnter: () => void;
|
||||||
|
onVolumeMouseLeave: () => void;
|
||||||
|
onSliderMouseEnter: () => void;
|
||||||
|
onSliderMouseLeave: () => void;
|
||||||
|
onSubtitleClick: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onCloseSubtitleMenu: () => void;
|
||||||
|
onSelectSubtitle: (index: number) => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onToggleLoop: () => void;
|
||||||
|
onControlsMouseEnter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlsOverlay: React.FC<ControlsOverlayProps> = ({
|
||||||
|
isFullscreen,
|
||||||
|
controlsVisible,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isDragging,
|
||||||
|
volume,
|
||||||
|
showVolumeSlider,
|
||||||
|
volumeSliderRef,
|
||||||
|
subtitles,
|
||||||
|
subtitlesEnabled,
|
||||||
|
isLooping,
|
||||||
|
subtitleMenuAnchor,
|
||||||
|
onPlayPause,
|
||||||
|
onSeek,
|
||||||
|
onProgressChange,
|
||||||
|
onProgressChangeCommitted,
|
||||||
|
onProgressMouseDown,
|
||||||
|
onVolumeChange,
|
||||||
|
onVolumeClick,
|
||||||
|
onVolumeMouseEnter,
|
||||||
|
onVolumeMouseLeave,
|
||||||
|
onSliderMouseEnter,
|
||||||
|
onSliderMouseLeave,
|
||||||
|
onSubtitleClick,
|
||||||
|
onCloseSubtitleMenu,
|
||||||
|
onSelectSubtitle,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onToggleLoop,
|
||||||
|
onControlsMouseEnter
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
bgcolor: theme.palette.mode === 'dark' ? '#1a1a1a' : '#f5f5f5',
|
||||||
|
opacity: isFullscreen
|
||||||
|
? (controlsVisible ? 0.3 : 0)
|
||||||
|
: 1,
|
||||||
|
visibility: isFullscreen && !controlsVisible ? 'hidden' : 'visible',
|
||||||
|
transition: 'opacity 0.3s, visibility 0.3s, background-color 0.3s',
|
||||||
|
pointerEvents: isFullscreen && !controlsVisible ? 'none' : 'auto',
|
||||||
|
'&:hover': {
|
||||||
|
opacity: isFullscreen && controlsVisible ? 1 : (isFullscreen ? 0 : 1)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={onControlsMouseEnter}
|
||||||
|
>
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Box sx={{ px: { xs: 0.5, sm: 2 }, mb: 1 }}>
|
||||||
|
<Stack direction="row" spacing={{ xs: 0.5, sm: 1 }} alignItems="center">
|
||||||
|
{/* Left Side: Volume and Play */}
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mr: { xs: 0.5, sm: 1 } }}>
|
||||||
|
<VolumeControl
|
||||||
|
volume={volume}
|
||||||
|
showVolumeSlider={showVolumeSlider}
|
||||||
|
volumeSliderRef={volumeSliderRef}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onVolumeClick={onVolumeClick}
|
||||||
|
onMouseEnter={onVolumeMouseEnter}
|
||||||
|
onMouseLeave={onVolumeMouseLeave}
|
||||||
|
onSliderMouseEnter={onSliderMouseEnter}
|
||||||
|
onSliderMouseLeave={onSliderMouseLeave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<Tooltip title={isPlaying ? t('paused') : t('playing')} disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
color={isPlaying ? "secondary" : "primary"}
|
||||||
|
onClick={onPlayPause}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onProgressChange={onProgressChange}
|
||||||
|
onProgressChangeCommitted={onProgressChangeCommitted}
|
||||||
|
onProgressMouseDown={onProgressMouseDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtitle Button (Mobile only, next to progress bar) */}
|
||||||
|
<SubtitleControl
|
||||||
|
subtitles={subtitles}
|
||||||
|
subtitlesEnabled={subtitlesEnabled}
|
||||||
|
subtitleMenuAnchor={subtitleMenuAnchor}
|
||||||
|
onSubtitleClick={onSubtitleClick}
|
||||||
|
onCloseMenu={onCloseSubtitleMenu}
|
||||||
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
|
showOnMobile={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Side: Fullscreen, Subtitle, Loop (Desktop only) */}
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ ml: 1, display: { xs: 'none', sm: 'flex' } }}>
|
||||||
|
<FullscreenControl
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggle={onToggleFullscreen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubtitleControl
|
||||||
|
subtitles={subtitles}
|
||||||
|
subtitlesEnabled={subtitlesEnabled}
|
||||||
|
subtitleMenuAnchor={subtitleMenuAnchor}
|
||||||
|
onSubtitleClick={onSubtitleClick}
|
||||||
|
onCloseMenu={onCloseSubtitleMenu}
|
||||||
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoopControl
|
||||||
|
isLooping={isLooping}
|
||||||
|
onToggle={onToggleLoop}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Seek Controls */}
|
||||||
|
<PlaybackControls
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onPlayPause={onPlayPause}
|
||||||
|
onSeek={onSeek}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile: Fullscreen, Loop */}
|
||||||
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ display: { xs: 'flex', sm: 'none' }, ml: 1, justifyContent: 'center', mt: 1 }}>
|
||||||
|
<FullscreenControl
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggle={onToggleFullscreen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoopControl
|
||||||
|
isLooping={isLooping}
|
||||||
|
onToggle={onToggleLoop}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlsOverlay;
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Fullscreen, FullscreenExit } from '@mui/icons-material';
|
||||||
|
import { IconButton, Tooltip, useMediaQuery } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface FullscreenControlProps {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullscreenControl: React.FC<FullscreenControlProps> = ({
|
||||||
|
isFullscreen,
|
||||||
|
onToggle
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={isFullscreen ? t('exitFullscreen') : t('enterFullscreen')} disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FullscreenControl;
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Loop } from '@mui/icons-material';
|
||||||
|
import { IconButton, Tooltip, useMediaQuery } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface LoopControlProps {
|
||||||
|
isLooping: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoopControl: React.FC<LoopControlProps> = ({
|
||||||
|
isLooping,
|
||||||
|
onToggle
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${t('loop')} ${isLooping ? t('on') : t('off')}`} disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
color={isLooping ? "primary" : "default"}
|
||||||
|
onClick={onToggle}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Loop />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoopControl;
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
FastForward,
|
||||||
|
FastRewind,
|
||||||
|
Forward10,
|
||||||
|
KeyboardDoubleArrowLeft,
|
||||||
|
KeyboardDoubleArrowRight,
|
||||||
|
Pause,
|
||||||
|
PlayArrow,
|
||||||
|
Replay10
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { IconButton, Stack, Tooltip, useMediaQuery } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface PlaybackControlsProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaybackControls: React.FC<PlaybackControlsProps> = ({
|
||||||
|
isPlaying,
|
||||||
|
onPlayPause,
|
||||||
|
onSeek
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.5}
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ width: '100%', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Tooltip title="-10m" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(-600)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<KeyboardDoubleArrowLeft />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="-1m" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(-60)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<FastRewind />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="-10s" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(-10)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<Replay10 />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="+10s" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(10)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<Forward10 />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="+1m" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(60)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<FastForward />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="+10m" disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onSeek(600)}
|
||||||
|
size="small"
|
||||||
|
sx={{ padding: { xs: '10px', sm: '8px' } }}
|
||||||
|
>
|
||||||
|
<KeyboardDoubleArrowRight />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaybackControls;
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Slider, Stack, Typography, useTheme } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
onProgressChange: (value: number) => void;
|
||||||
|
onProgressChangeCommitted: (value: number) => void;
|
||||||
|
onProgressMouseDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
onProgressChange,
|
||||||
|
onProgressChangeCommitted,
|
||||||
|
onProgressMouseDown
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds) || !isFinite(seconds)) return '0:00';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={{ xs: 0.5, sm: 1 }} alignItems="center" sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" sx={{ minWidth: { xs: '35px', sm: '45px' }, textAlign: 'right', fontSize: '0.75rem' }}>
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={duration > 0 && isFinite(duration) ? (currentTime / duration) * 100 : 0}
|
||||||
|
onChange={(_event, newValue) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
onProgressChange(value);
|
||||||
|
}}
|
||||||
|
onChangeCommitted={(_event, newValue) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
onProgressChangeCommitted(value);
|
||||||
|
}}
|
||||||
|
onMouseDown={onProgressMouseDown}
|
||||||
|
disabled={duration <= 0 || !isFinite(duration)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'& .MuiSlider-thumb': {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
transition: 'width 0.2s, height 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiSlider-track': {
|
||||||
|
height: 4,
|
||||||
|
transition: 'height 0.2s ease',
|
||||||
|
},
|
||||||
|
'& .MuiSlider-rail': {
|
||||||
|
height: 4,
|
||||||
|
transition: 'height 0.2s ease',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
'& .MuiSlider-track': {
|
||||||
|
height: 8,
|
||||||
|
},
|
||||||
|
'& .MuiSlider-rail': {
|
||||||
|
height: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ minWidth: { xs: '35px', sm: '45px' }, textAlign: 'left', fontSize: '0.75rem' }}>
|
||||||
|
{formatTime(duration)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Subtitles, SubtitlesOff } from '@mui/icons-material';
|
||||||
|
import { IconButton, Menu, MenuItem, Tooltip, useMediaQuery } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface SubtitleControlProps {
|
||||||
|
subtitles: Array<{ language: string; filename: string; path: string }>;
|
||||||
|
subtitlesEnabled: boolean;
|
||||||
|
subtitleMenuAnchor: HTMLElement | null;
|
||||||
|
onSubtitleClick: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
onCloseMenu: () => void;
|
||||||
|
onSelectSubtitle: (index: number) => void;
|
||||||
|
showOnMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubtitleControl: React.FC<SubtitleControlProps> = ({
|
||||||
|
subtitles,
|
||||||
|
subtitlesEnabled,
|
||||||
|
subtitleMenuAnchor,
|
||||||
|
onSubtitleClick,
|
||||||
|
onCloseMenu,
|
||||||
|
onSelectSubtitle,
|
||||||
|
showOnMobile = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
if (!subtitles || subtitles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title={subtitlesEnabled ? 'Subtitles' : 'Subtitles Off'} disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
color={subtitlesEnabled ? "primary" : "default"}
|
||||||
|
onClick={onSubtitleClick}
|
||||||
|
size="small"
|
||||||
|
sx={showOnMobile ? { display: { xs: 'flex', sm: 'none' }, ml: { xs: 0.25, sm: 0.5 } } : {}}
|
||||||
|
>
|
||||||
|
{subtitlesEnabled ? <Subtitles /> : <SubtitlesOff />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={subtitleMenuAnchor}
|
||||||
|
open={Boolean(subtitleMenuAnchor)}
|
||||||
|
onClose={onCloseMenu}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => onSelectSubtitle(-1)}>
|
||||||
|
{t('off') || 'Off'}
|
||||||
|
</MenuItem>
|
||||||
|
{subtitles.map((subtitle, index) => (
|
||||||
|
<MenuItem key={subtitle.language} onClick={() => onSelectSubtitle(index)}>
|
||||||
|
{subtitle.language.toUpperCase()}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubtitleControl;
|
||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface VideoElementProps {
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
src: string;
|
||||||
|
poster?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
loadError: string | null;
|
||||||
|
subtitles: Array<{ language: string; filename: string; path: string }>;
|
||||||
|
onClick: () => void;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
onTimeUpdate: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
|
onLoadedMetadata: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
|
onError: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
|
onLoadStart: () => void;
|
||||||
|
onCanPlay: () => void;
|
||||||
|
onLoadedData: () => void;
|
||||||
|
onSubtitleInit: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoElement: React.FC<VideoElementProps> = ({
|
||||||
|
videoRef,
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
|
subtitles,
|
||||||
|
onClick,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onError,
|
||||||
|
onLoadStart,
|
||||||
|
onCanPlay,
|
||||||
|
onLoadedData,
|
||||||
|
onSubtitleInit
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
// Use useMemo to generate a stable unique ID per component instance
|
||||||
|
// Using Date.now() and a simple counter is safe for non-cryptographic purposes
|
||||||
|
const videoId = useMemo(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const counter = (globalThis as any).__videoControlCounter = ((globalThis as any).__videoControlCounter || 0) + 1;
|
||||||
|
return `video-controls-${Date.now()}-${counter}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Scoped style for centering subtitles */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
#${videoId}::cue {
|
||||||
|
text-align: center !important;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${videoId}::-webkit-media-text-track-display {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${videoId}::-webkit-media-text-track-container {
|
||||||
|
text-align: center !important;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${videoId}::cue-region {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
bgcolor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{t('loadingVideo') || 'Loading video...'}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{loadError && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.9)',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
color: 'white',
|
||||||
|
maxWidth: '80%',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{loadError}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<video
|
||||||
|
id={videoId}
|
||||||
|
ref={videoRef}
|
||||||
|
style={{ width: '100%', aspectRatio: '16/9', display: 'block', cursor: 'pointer' }}
|
||||||
|
controls={false}
|
||||||
|
src={src}
|
||||||
|
preload="metadata"
|
||||||
|
onClick={onClick}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
onLoadedMetadata(e);
|
||||||
|
onSubtitleInit(e);
|
||||||
|
}}
|
||||||
|
onError={onError}
|
||||||
|
onLoadStart={onLoadStart}
|
||||||
|
onCanPlay={onCanPlay}
|
||||||
|
onLoadedData={onLoadedData}
|
||||||
|
playsInline
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
poster={poster}
|
||||||
|
>
|
||||||
|
{subtitles && subtitles.map((subtitle) => (
|
||||||
|
<track
|
||||||
|
key={subtitle.language}
|
||||||
|
kind="subtitles"
|
||||||
|
src={`${import.meta.env.VITE_BACKEND_URL}${subtitle.path}`}
|
||||||
|
srcLang={subtitle.language}
|
||||||
|
label={subtitle.language.toUpperCase()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoElement;
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { VolumeDown, VolumeOff, VolumeUp } from '@mui/icons-material';
|
||||||
|
import { Box, IconButton, Slider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface VolumeControlProps {
|
||||||
|
volume: number;
|
||||||
|
showVolumeSlider: boolean;
|
||||||
|
volumeSliderRef: React.RefObject<HTMLDivElement>;
|
||||||
|
onVolumeChange: (value: number) => void;
|
||||||
|
onVolumeClick: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
onSliderMouseEnter: () => void;
|
||||||
|
onSliderMouseLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VolumeControl: React.FC<VolumeControlProps> = ({
|
||||||
|
volume,
|
||||||
|
showVolumeSlider,
|
||||||
|
volumeSliderRef,
|
||||||
|
onVolumeChange,
|
||||||
|
onVolumeClick,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
onSliderMouseEnter,
|
||||||
|
onSliderMouseLeave
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||||
|
|
||||||
|
const getVolumeIcon = () => {
|
||||||
|
if (volume === 0) return <VolumeOff />;
|
||||||
|
if (volume < 0.5) return <VolumeDown />;
|
||||||
|
return <VolumeUp />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={volumeSliderRef}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
display: { xs: 'none', md: 'flex' },
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<Tooltip title={volume === 0 ? 'Unmute' : 'Mute'} disableHoverListener={isTouch}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onVolumeClick}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{getVolumeIcon()}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{showVolumeSlider && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
mb: 0.5,
|
||||||
|
width: '40px',
|
||||||
|
bgcolor: theme.palette.mode === 'dark' ? '#2a2a2a' : '#fff',
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
boxShadow: 2,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
onMouseEnter={onSliderMouseEnter}
|
||||||
|
onMouseLeave={onSliderMouseLeave}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
orientation="vertical"
|
||||||
|
value={volume * 100}
|
||||||
|
onChange={(_event, newValue) => {
|
||||||
|
const value = Array.isArray(newValue) ? newValue[0] : newValue;
|
||||||
|
onVolumeChange(value);
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: '80px',
|
||||||
|
'& .MuiSlider-thumb': {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
},
|
||||||
|
'& .MuiSlider-track': {
|
||||||
|
width: 4,
|
||||||
|
},
|
||||||
|
'& .MuiSlider-rail': {
|
||||||
|
width: 4,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeControl;
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export const useFullscreen = (
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||||
|
) => {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState<boolean>(true);
|
||||||
|
const hideControlsTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebkitBeginFullscreen = () => {
|
||||||
|
setIsFullscreen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebkitEndFullscreen = () => {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.addEventListener(
|
||||||
|
"webkitbeginfullscreen",
|
||||||
|
handleWebkitBeginFullscreen
|
||||||
|
);
|
||||||
|
videoElement.addEventListener(
|
||||||
|
"webkitendfullscreen",
|
||||||
|
handleWebkitEndFullscreen
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.removeEventListener(
|
||||||
|
"webkitbeginfullscreen",
|
||||||
|
handleWebkitBeginFullscreen
|
||||||
|
);
|
||||||
|
videoElement.removeEventListener(
|
||||||
|
"webkitendfullscreen",
|
||||||
|
handleWebkitEndFullscreen
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
// Handle controls visibility in fullscreen mode
|
||||||
|
useEffect(() => {
|
||||||
|
const startHideTimer = () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
setControlsVisible(true);
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startHideTimer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
// Handle mouse movement to show controls in fullscreen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
setControlsVisible(true);
|
||||||
|
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = videoContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener("mousemove", handleMouseMove);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
const handleToggleFullscreen = () => {
|
||||||
|
const videoContainer = videoRef.current?.parentElement;
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
|
||||||
|
if (!videoContainer || !videoElement) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
if (videoContainer.requestFullscreen) {
|
||||||
|
videoContainer.requestFullscreen().catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`Error attempting to enable fullscreen: ${err.message}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if ((videoElement as any).webkitEnterFullscreen) {
|
||||||
|
(videoElement as any).webkitEnterFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlsMouseEnter = () => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFullscreen,
|
||||||
|
controlsVisible,
|
||||||
|
videoContainerRef,
|
||||||
|
handleToggleFullscreen,
|
||||||
|
handleControlsMouseEnter,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UseKeyboardShortcutsProps {
|
||||||
|
onSeekLeft: () => void;
|
||||||
|
onSeekRight: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKeyboardShortcuts = ({ onSeekLeft, onSeekRight }: UseKeyboardShortcutsProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ignore if typing in an input or textarea
|
||||||
|
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
onSeekLeft();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
onSeekRight();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onSeekLeft, onSeekRight]);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Subtitle {
|
||||||
|
language: string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSubtitlesProps {
|
||||||
|
subtitles: Subtitle[];
|
||||||
|
initialSubtitlesEnabled: boolean;
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
onSubtitlesToggle?: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubtitles = ({
|
||||||
|
subtitles,
|
||||||
|
initialSubtitlesEnabled,
|
||||||
|
videoRef,
|
||||||
|
onSubtitlesToggle
|
||||||
|
}: UseSubtitlesProps) => {
|
||||||
|
const [subtitlesEnabled, setSubtitlesEnabled] = useState<boolean>(
|
||||||
|
initialSubtitlesEnabled && subtitles.length > 0
|
||||||
|
);
|
||||||
|
const [subtitleMenuAnchor, setSubtitleMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
// Sync subtitle tracks when preference changes or subtitles become available
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current && subtitles.length > 0) {
|
||||||
|
const tracks = videoRef.current.textTracks;
|
||||||
|
const newState = initialSubtitlesEnabled;
|
||||||
|
setSubtitlesEnabled(newState);
|
||||||
|
|
||||||
|
// Hide all first
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
tracks[i].mode = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If enabled, show the first one
|
||||||
|
if (newState && tracks.length > 0) {
|
||||||
|
tracks[0].mode = 'showing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialSubtitlesEnabled, subtitles, videoRef]);
|
||||||
|
|
||||||
|
const handleSubtitleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setSubtitleMenuAnchor(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSubtitleMenu = () => {
|
||||||
|
setSubtitleMenuAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSubtitle = (index: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const tracks = videoRef.current.textTracks;
|
||||||
|
|
||||||
|
// Hide all tracks first
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
tracks[i].mode = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0 && index < tracks.length) {
|
||||||
|
tracks[index].mode = 'showing';
|
||||||
|
setSubtitlesEnabled(true);
|
||||||
|
if (onSubtitlesToggle) onSubtitlesToggle(true);
|
||||||
|
} else {
|
||||||
|
setSubtitlesEnabled(false);
|
||||||
|
if (onSubtitlesToggle) onSubtitlesToggle(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleCloseSubtitleMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSubtitles = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const tracks = e.currentTarget.textTracks;
|
||||||
|
const shouldShow = initialSubtitlesEnabled && subtitles.length > 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
tracks[i].mode = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShow && tracks.length > 0) {
|
||||||
|
tracks[0].mode = 'showing';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtitlesEnabled,
|
||||||
|
subtitleMenuAnchor,
|
||||||
|
handleSubtitleClick,
|
||||||
|
handleCloseSubtitleMenu,
|
||||||
|
handleSelectSubtitle,
|
||||||
|
initializeSubtitles
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const useVideoLoading = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (loadTimeoutRef.current) {
|
||||||
|
clearTimeout(loadTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLoading = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
|
if (loadTimeoutRef.current) {
|
||||||
|
clearTimeout(loadTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a timeout for loading (30 seconds for large files)
|
||||||
|
loadTimeoutRef.current = setTimeout(() => {
|
||||||
|
setLoadError('Video is taking too long to load. Please try again or check your connection.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopLoading = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (loadTimeoutRef.current) {
|
||||||
|
clearTimeout(loadTimeoutRef.current);
|
||||||
|
loadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (error: string | null) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setLoadError(error);
|
||||||
|
if (loadTimeoutRef.current) {
|
||||||
|
clearTimeout(loadTimeoutRef.current);
|
||||||
|
loadTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
console.error('Video error:', e);
|
||||||
|
setIsLoading(false);
|
||||||
|
const videoElement = e.currentTarget;
|
||||||
|
if (videoElement.error) {
|
||||||
|
console.error('Video error code:', videoElement.error.code);
|
||||||
|
console.error('Video error message:', videoElement.error.message);
|
||||||
|
let errorMessage = 'Failed to load video.';
|
||||||
|
switch (videoElement.error?.code) {
|
||||||
|
case 1: // MEDIA_ERR_ABORTED
|
||||||
|
errorMessage = 'Video loading was aborted.';
|
||||||
|
break;
|
||||||
|
case 2: // MEDIA_ERR_NETWORK
|
||||||
|
errorMessage = 'Network error while loading video. Please check your connection.';
|
||||||
|
break;
|
||||||
|
case 3: // MEDIA_ERR_DECODE
|
||||||
|
errorMessage = 'Video decoding error. The file may be corrupted.';
|
||||||
|
break;
|
||||||
|
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||||
|
errorMessage = 'Video format not supported.';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setLoadError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
|
startLoading,
|
||||||
|
stopLoading,
|
||||||
|
setError,
|
||||||
|
handleVideoError
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface UseVideoPlayerProps {
|
||||||
|
src: string;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
autoLoop?: boolean;
|
||||||
|
startTime?: number;
|
||||||
|
onTimeUpdate?: (currentTime: number) => void;
|
||||||
|
onLoadedMetadata?: (duration: number) => void;
|
||||||
|
// onEnded is passed through to the video element via the component, not used in this hook
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoPlayer = ({
|
||||||
|
src,
|
||||||
|
autoPlay = false,
|
||||||
|
autoLoop = false,
|
||||||
|
startTime = 0,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
}: UseVideoPlayerProps) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [isLooping, setIsLooping] = useState<boolean>(autoLoop);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const videoSrcRef = useRef<string>("");
|
||||||
|
|
||||||
|
// Memory management: Clean up video source when component unmounts or src changes
|
||||||
|
useEffect(() => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
// Store previous src for cleanup
|
||||||
|
const previousSrc = videoSrcRef.current;
|
||||||
|
videoSrcRef.current = src;
|
||||||
|
|
||||||
|
// Clean up previous source to free memory
|
||||||
|
if (previousSrc && previousSrc !== src) {
|
||||||
|
videoElement.pause();
|
||||||
|
videoElement.src = "";
|
||||||
|
videoElement.load();
|
||||||
|
// Reset state when src changes to prevent stale data flash
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new source
|
||||||
|
if (src) {
|
||||||
|
videoElement.preload = "metadata";
|
||||||
|
videoElement.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup on unmount
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.pause();
|
||||||
|
videoElement.src = "";
|
||||||
|
videoElement.load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (autoPlay) {
|
||||||
|
videoRef.current.autoplay = true;
|
||||||
|
}
|
||||||
|
if (autoLoop) {
|
||||||
|
videoRef.current.loop = true;
|
||||||
|
setIsLooping(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoPlay, autoLoop]);
|
||||||
|
|
||||||
|
// Listen for duration changes
|
||||||
|
useEffect(() => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
const handleDurationChange = () => {
|
||||||
|
const videoDuration = videoElement.duration;
|
||||||
|
setDuration(videoDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
videoElement.addEventListener("durationchange", handleDurationChange);
|
||||||
|
return () => {
|
||||||
|
videoElement.removeEventListener("durationchange", handleDurationChange);
|
||||||
|
};
|
||||||
|
}, [videoRef]); // Include videoRef to ensure listener is attached when ref is ready
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (seconds: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime += seconds;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressChange = (newValue: number) => {
|
||||||
|
if (!videoRef.current || duration <= 0 || !isFinite(duration)) return;
|
||||||
|
const newTime = (newValue / 100) * duration;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressChangeCommitted = (newValue: number) => {
|
||||||
|
if (videoRef.current && duration > 0 && isFinite(duration)) {
|
||||||
|
const newTime = (newValue / 100) * duration;
|
||||||
|
videoRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressMouseDown = () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleLoop = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newState = !isLooping;
|
||||||
|
videoRef.current.loop = newState;
|
||||||
|
setIsLooping(newState);
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
return isLooping;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const time = e.currentTarget.currentTime;
|
||||||
|
if (!isDragging) {
|
||||||
|
setCurrentTime(time);
|
||||||
|
}
|
||||||
|
if (onTimeUpdate) {
|
||||||
|
onTimeUpdate(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const videoDuration = e.currentTarget.duration;
|
||||||
|
setDuration(videoDuration);
|
||||||
|
if (startTime > 0) {
|
||||||
|
e.currentTarget.currentTime = startTime;
|
||||||
|
setCurrentTime(startTime);
|
||||||
|
}
|
||||||
|
if (onLoadedMetadata) {
|
||||||
|
onLoadedMetadata(videoDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoRef,
|
||||||
|
isPlaying,
|
||||||
|
isLooping,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isDragging,
|
||||||
|
handlePlayPause,
|
||||||
|
handleSeek,
|
||||||
|
handleProgressChange,
|
||||||
|
handleProgressChangeCommitted,
|
||||||
|
handleProgressMouseDown,
|
||||||
|
handleToggleLoop,
|
||||||
|
handleTimeUpdate,
|
||||||
|
handleLoadedMetadata,
|
||||||
|
handlePlay,
|
||||||
|
handlePause,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const useVolume = (videoRef: React.RefObject<HTMLVideoElement | null>) => {
|
||||||
|
const [volume, setVolume] = useState<number>(1);
|
||||||
|
const [previousVolume, setPreviousVolume] = useState<number>(1);
|
||||||
|
const [showVolumeSlider, setShowVolumeSlider] = useState<boolean>(false);
|
||||||
|
const volumeSliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const volumeSliderHideTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
}
|
||||||
|
}, [volume, videoRef]);
|
||||||
|
|
||||||
|
// Close volume slider when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (volumeSliderRef.current && !volumeSliderRef.current.contains(event.target as Node)) {
|
||||||
|
if (volumeSliderHideTimerRef.current) {
|
||||||
|
clearTimeout(volumeSliderHideTimerRef.current);
|
||||||
|
}
|
||||||
|
setShowVolumeSlider(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showVolumeSlider) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showVolumeSlider]);
|
||||||
|
|
||||||
|
// Handle wheel event on volume control
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (volumeSliderRef.current && volumeSliderRef.current.contains(event.target as Node)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (videoRef.current) {
|
||||||
|
const delta = event.deltaY > 0 ? 0.05 : -0.05;
|
||||||
|
const newVolume = Math.max(0, Math.min(1, volume + delta));
|
||||||
|
videoRef.current.volume = newVolume;
|
||||||
|
setVolume(newVolume);
|
||||||
|
if (newVolume > 0) {
|
||||||
|
setPreviousVolume(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = volumeSliderRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [volume, videoRef]);
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (volumeSliderHideTimerRef.current) {
|
||||||
|
clearTimeout(volumeSliderHideTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVolumeChange = (newValue: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const volumeValue = newValue / 100;
|
||||||
|
videoRef.current.volume = volumeValue;
|
||||||
|
setVolume(volumeValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeClick = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (volume > 0) {
|
||||||
|
setPreviousVolume(volume);
|
||||||
|
videoRef.current.volume = 0;
|
||||||
|
setVolume(0);
|
||||||
|
} else {
|
||||||
|
const volumeToRestore = previousVolume > 0 ? previousVolume : 1;
|
||||||
|
videoRef.current.volume = volumeToRestore;
|
||||||
|
setVolume(volumeToRestore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeMouseEnter = () => {
|
||||||
|
if (volumeSliderHideTimerRef.current) {
|
||||||
|
clearTimeout(volumeSliderHideTimerRef.current);
|
||||||
|
}
|
||||||
|
setShowVolumeSlider(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeMouseLeave = () => {
|
||||||
|
volumeSliderHideTimerRef.current = setTimeout(() => {
|
||||||
|
setShowVolumeSlider(false);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseEnter = () => {
|
||||||
|
if (volumeSliderHideTimerRef.current) {
|
||||||
|
clearTimeout(volumeSliderHideTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseLeave = () => {
|
||||||
|
volumeSliderHideTimerRef.current = setTimeout(() => {
|
||||||
|
setShowVolumeSlider(false);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
volume,
|
||||||
|
showVolumeSlider,
|
||||||
|
volumeSliderRef,
|
||||||
|
handleVolumeChange,
|
||||||
|
handleVolumeClick,
|
||||||
|
handleVolumeMouseEnter,
|
||||||
|
handleVolumeMouseLeave,
|
||||||
|
handleSliderMouseEnter,
|
||||||
|
handleSliderMouseLeave
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
178
frontend/src/components/VideoPlayer/VideoControls/index.tsx
Normal file
178
frontend/src/components/VideoPlayer/VideoControls/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import ControlsOverlay from './ControlsOverlay';
|
||||||
|
import { useFullscreen } from './hooks/useFullscreen';
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
|
import { useSubtitles } from './hooks/useSubtitles';
|
||||||
|
import { useVideoLoading } from './hooks/useVideoLoading';
|
||||||
|
import { useVideoPlayer } from './hooks/useVideoPlayer';
|
||||||
|
import { useVolume } from './hooks/useVolume';
|
||||||
|
import VideoElement from './VideoElement';
|
||||||
|
|
||||||
|
interface VideoControlsProps {
|
||||||
|
src: string;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
autoLoop?: boolean;
|
||||||
|
onTimeUpdate?: (currentTime: number) => void;
|
||||||
|
onLoadedMetadata?: (duration: number) => void;
|
||||||
|
startTime?: number;
|
||||||
|
subtitles?: Array<{ language: string; filename: string; path: string }>;
|
||||||
|
subtitlesEnabled?: boolean;
|
||||||
|
onSubtitlesToggle?: (enabled: boolean) => void;
|
||||||
|
onLoopToggle?: (enabled: boolean) => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
poster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoControls: React.FC<VideoControlsProps> = ({
|
||||||
|
src,
|
||||||
|
autoPlay = false,
|
||||||
|
autoLoop = false,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata,
|
||||||
|
startTime = 0,
|
||||||
|
subtitles = [],
|
||||||
|
subtitlesEnabled: initialSubtitlesEnabled = true,
|
||||||
|
onSubtitlesToggle,
|
||||||
|
onLoopToggle,
|
||||||
|
onEnded,
|
||||||
|
poster
|
||||||
|
}) => {
|
||||||
|
// Core video player logic
|
||||||
|
const videoPlayer = useVideoPlayer({
|
||||||
|
src,
|
||||||
|
autoPlay,
|
||||||
|
autoLoop,
|
||||||
|
startTime,
|
||||||
|
onTimeUpdate,
|
||||||
|
onLoadedMetadata
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fullscreen management
|
||||||
|
const fullscreen = useFullscreen(videoPlayer.videoRef);
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
const loading = useVideoLoading();
|
||||||
|
|
||||||
|
// Volume control
|
||||||
|
const volume = useVolume(videoPlayer.videoRef);
|
||||||
|
|
||||||
|
// Subtitle management
|
||||||
|
const subtitlesHook = useSubtitles({
|
||||||
|
subtitles,
|
||||||
|
initialSubtitlesEnabled,
|
||||||
|
videoRef: videoPlayer.videoRef,
|
||||||
|
onSubtitlesToggle
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onSeekLeft: () => videoPlayer.handleSeek(-10),
|
||||||
|
onSeekRight: () => videoPlayer.handleSeek(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle video source changes - trigger loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (src) {
|
||||||
|
loading.startLoading();
|
||||||
|
} else {
|
||||||
|
loading.stopLoading();
|
||||||
|
loading.setError(null);
|
||||||
|
}
|
||||||
|
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Handle video loading events
|
||||||
|
const handleLoadStart = () => {
|
||||||
|
loading.startLoading();
|
||||||
|
const videoElement = videoPlayer.videoRef.current;
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.preload = 'metadata';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
loading.stopLoading();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedData = () => {
|
||||||
|
loading.stopLoading();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
loading.stopLoading();
|
||||||
|
videoPlayer.handleLoadedMetadata(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: onVolumeChange from video element is handled by the useVolume hook
|
||||||
|
// through the useEffect that syncs volume state with the video element
|
||||||
|
|
||||||
|
const handleToggleLoop = () => {
|
||||||
|
const newState = videoPlayer.handleToggleLoop();
|
||||||
|
if (onLoopToggle) {
|
||||||
|
onLoopToggle(newState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={fullscreen.videoContainerRef}
|
||||||
|
sx={{ width: '100%', bgcolor: 'black', borderRadius: { xs: 0, sm: 2 }, overflow: 'hidden', boxShadow: 4, position: 'relative' }}
|
||||||
|
>
|
||||||
|
<VideoElement
|
||||||
|
videoRef={videoPlayer.videoRef}
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
isLoading={loading.isLoading}
|
||||||
|
loadError={loading.loadError}
|
||||||
|
subtitles={subtitles}
|
||||||
|
onClick={videoPlayer.handlePlayPause}
|
||||||
|
onPlay={videoPlayer.handlePlay}
|
||||||
|
onPause={videoPlayer.handlePause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={videoPlayer.handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onError={loading.handleVideoError}
|
||||||
|
onLoadStart={handleLoadStart}
|
||||||
|
onCanPlay={handleCanPlay}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
onSubtitleInit={subtitlesHook.initializeSubtitles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlsOverlay
|
||||||
|
isFullscreen={fullscreen.isFullscreen}
|
||||||
|
controlsVisible={fullscreen.controlsVisible}
|
||||||
|
isPlaying={videoPlayer.isPlaying}
|
||||||
|
currentTime={videoPlayer.currentTime}
|
||||||
|
duration={videoPlayer.duration}
|
||||||
|
isDragging={videoPlayer.isDragging}
|
||||||
|
volume={volume.volume}
|
||||||
|
showVolumeSlider={volume.showVolumeSlider}
|
||||||
|
volumeSliderRef={volume.volumeSliderRef}
|
||||||
|
subtitles={subtitles}
|
||||||
|
subtitlesEnabled={subtitlesHook.subtitlesEnabled}
|
||||||
|
isLooping={videoPlayer.isLooping}
|
||||||
|
subtitleMenuAnchor={subtitlesHook.subtitleMenuAnchor}
|
||||||
|
onPlayPause={videoPlayer.handlePlayPause}
|
||||||
|
onSeek={videoPlayer.handleSeek}
|
||||||
|
onProgressChange={videoPlayer.handleProgressChange}
|
||||||
|
onProgressChangeCommitted={videoPlayer.handleProgressChangeCommitted}
|
||||||
|
onProgressMouseDown={videoPlayer.handleProgressMouseDown}
|
||||||
|
onVolumeChange={volume.handleVolumeChange}
|
||||||
|
onVolumeClick={volume.handleVolumeClick}
|
||||||
|
onVolumeMouseEnter={volume.handleVolumeMouseEnter}
|
||||||
|
onVolumeMouseLeave={volume.handleVolumeMouseLeave}
|
||||||
|
onSliderMouseEnter={volume.handleSliderMouseEnter}
|
||||||
|
onSliderMouseLeave={volume.handleSliderMouseLeave}
|
||||||
|
onSubtitleClick={subtitlesHook.handleSubtitleClick}
|
||||||
|
onCloseSubtitleMenu={subtitlesHook.handleCloseSubtitleMenu}
|
||||||
|
onSelectSubtitle={subtitlesHook.handleSelectSubtitle}
|
||||||
|
onToggleFullscreen={fullscreen.handleToggleFullscreen}
|
||||||
|
onToggleLoop={handleToggleLoop}
|
||||||
|
onControlsMouseEnter={fullscreen.handleControlsMouseEnter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoControls;
|
||||||
|
|
||||||
Reference in New Issue
Block a user