refactor: breakdown VideoControls

This commit is contained in:
Peifan Li
2025-12-27 22:24:29 -05:00
parent f85c6f4cc6
commit d33d3b144a
16 changed files with 1629 additions and 1090 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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]);
};

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -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,
};
};

View File

@@ -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
};
};

View 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;