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