273 lines
7.7 KiB
TypeScript
273 lines
7.7 KiB
TypeScript
import React, {
|
|
FC, useEffect, useRef, useCallback, useMemo,
|
|
} from '../../lib/teact/teact';
|
|
import buildClassName from '../../util/buildClassName';
|
|
|
|
import useFlag from '../../hooks/useFlag';
|
|
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
|
import { formatMediaDuration } from '../../util/dateFormat';
|
|
import formatFileSize from './helpers/formatFileSize';
|
|
import useLang from '../../hooks/useLang';
|
|
import { BufferedRange } from '../../hooks/useBuffering';
|
|
import { captureEvents } from '../../util/captureEvents';
|
|
|
|
import Button from '../ui/Button';
|
|
import RangeSlider from '../ui/RangeSlider';
|
|
import Menu from '../ui/Menu';
|
|
import MenuItem from '../ui/MenuItem';
|
|
|
|
import './VideoPlayerControls.scss';
|
|
|
|
type OwnProps = {
|
|
bufferedRanges: BufferedRange[];
|
|
bufferedProgress: number;
|
|
currentTime: number;
|
|
duration: number;
|
|
fileSize: number;
|
|
isForceMobileVersion?: boolean;
|
|
isPlayed: boolean;
|
|
isFullscreenSupported: boolean;
|
|
isFullscreen: boolean;
|
|
isVisible: boolean;
|
|
isBuffered: boolean;
|
|
volume: number;
|
|
isMuted: boolean;
|
|
playbackRate: number;
|
|
onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
onVolumeClick: () => void;
|
|
onVolumeChange: (volume: number) => void;
|
|
onPlaybackRateChange: (playbackRate: number) => void;
|
|
onPlayPause: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
setVisibility: (isVisible: boolean) => void;
|
|
onSeek: (position: number) => void;
|
|
};
|
|
|
|
const stopEvent = (e: React.MouseEvent<HTMLElement>) => {
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const PLAYBACK_RATES = [
|
|
0.5,
|
|
1,
|
|
1.5,
|
|
2,
|
|
];
|
|
|
|
const HIDE_CONTROLS_TIMEOUT_MS = 1500;
|
|
|
|
const VideoPlayerControls: FC<OwnProps> = ({
|
|
bufferedRanges,
|
|
bufferedProgress,
|
|
currentTime,
|
|
duration,
|
|
fileSize,
|
|
isForceMobileVersion,
|
|
isPlayed,
|
|
isFullscreenSupported,
|
|
isFullscreen,
|
|
isVisible,
|
|
isBuffered,
|
|
volume,
|
|
isMuted,
|
|
playbackRate,
|
|
onChangeFullscreen,
|
|
onVolumeClick,
|
|
onVolumeChange,
|
|
onPlaybackRateChange,
|
|
onPlayPause,
|
|
setVisibility,
|
|
onSeek,
|
|
}) => {
|
|
const [isPlaybackMenuOpen, openPlaybackMenu, closePlaybackMenu] = useFlag();
|
|
// eslint-disable-next-line no-null/no-null
|
|
const seekerRef = useRef<HTMLDivElement>(null);
|
|
const isSeekingRef = useRef<boolean>(false);
|
|
const isSeeking = isSeekingRef.current;
|
|
|
|
useEffect(() => {
|
|
let timeout: number | undefined;
|
|
if (!isVisible || !isPlayed || isSeeking || isPlaybackMenuOpen) {
|
|
if (timeout) window.clearTimeout(timeout);
|
|
return undefined;
|
|
}
|
|
timeout = window.setTimeout(() => {
|
|
setVisibility(false);
|
|
}, HIDE_CONTROLS_TIMEOUT_MS);
|
|
return () => {
|
|
if (timeout) window.clearTimeout(timeout);
|
|
};
|
|
}, [isPlayed, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
document.body.classList.add('video-controls-visible');
|
|
} else {
|
|
document.body.classList.remove('video-controls-visible');
|
|
}
|
|
return () => {
|
|
document.body.classList.remove('video-controls-visible');
|
|
};
|
|
}, [isVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) {
|
|
closePlaybackMenu();
|
|
}
|
|
}, [closePlaybackMenu, isVisible]);
|
|
|
|
const lang = useLang();
|
|
|
|
const handleSeek = useCallback((e: MouseEvent | TouchEvent) => {
|
|
if (isSeekingRef.current && seekerRef.current) {
|
|
const {
|
|
width,
|
|
left,
|
|
} = seekerRef.current.getBoundingClientRect();
|
|
const clientX = e instanceof MouseEvent ? e.clientX : e.targetTouches[0].clientX;
|
|
onSeek(Math.max(Math.min(duration * ((clientX - left) / width), duration), 0));
|
|
}
|
|
}, [duration, onSeek]);
|
|
|
|
const handleStartSeek = useCallback((e: MouseEvent | TouchEvent) => {
|
|
isSeekingRef.current = true;
|
|
handleSeek(e);
|
|
}, [handleSeek]);
|
|
|
|
const handleStopSeek = useCallback(() => {
|
|
isSeekingRef.current = false;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!seekerRef.current || !isVisible) return undefined;
|
|
return captureEvents(seekerRef.current, {
|
|
onCapture: handleStartSeek,
|
|
onRelease: handleStopSeek,
|
|
onClick: handleStopSeek,
|
|
onDrag: handleSeek,
|
|
});
|
|
}, [isVisible, handleStartSeek, handleSeek, handleStopSeek]);
|
|
|
|
const volumeIcon = useMemo(() => {
|
|
if (volume === 0 || isMuted) return 'icon-muted';
|
|
if (volume < 0.3) return 'icon-volume-1';
|
|
if (volume < 0.6) return 'icon-volume-2';
|
|
return 'icon-volume-3';
|
|
}, [volume, isMuted]);
|
|
|
|
return (
|
|
<div
|
|
className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isVisible && 'active')}
|
|
onClick={stopEvent}
|
|
>
|
|
{renderSeekLine(currentTime, duration, bufferedRanges, seekerRef)}
|
|
<div className="buttons">
|
|
<Button
|
|
ariaLabel={lang('AccActionPlay')}
|
|
size="tiny"
|
|
ripple={!IS_SINGLE_COLUMN_LAYOUT}
|
|
color="translucent-white"
|
|
className="play"
|
|
round
|
|
onClick={onPlayPause}
|
|
>
|
|
<i className={isPlayed ? 'icon-pause' : 'icon-play'} />
|
|
</Button>
|
|
<Button
|
|
ariaLabel="Volume"
|
|
size="tiny"
|
|
color="translucent-white"
|
|
className="volume"
|
|
round
|
|
onClick={onVolumeClick}
|
|
>
|
|
<i className={volumeIcon} />
|
|
</Button>
|
|
{!IS_IOS && (
|
|
<RangeSlider bold className="volume-slider" value={isMuted ? 0 : volume * 100} onChange={onVolumeChange} />
|
|
)}
|
|
{renderTime(currentTime, duration)}
|
|
{!isBuffered && renderFileSize(bufferedProgress, fileSize)}
|
|
<div className="spacer" />
|
|
<Button
|
|
ariaLabel="Playback rate"
|
|
size="tiny"
|
|
color="translucent-white"
|
|
className="playback-rate"
|
|
round
|
|
onClick={openPlaybackMenu}
|
|
>
|
|
{`${playbackRate}x`}
|
|
</Button>
|
|
{isFullscreenSupported && (
|
|
<Button
|
|
ariaLabel="Fullscreen"
|
|
size="tiny"
|
|
color="translucent-white"
|
|
className="fullscreen"
|
|
round
|
|
onClick={onChangeFullscreen}
|
|
>
|
|
<i className={isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen'} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Menu
|
|
isOpen={isPlaybackMenuOpen}
|
|
className="playback-rate-menu"
|
|
positionX="right"
|
|
positionY="bottom"
|
|
autoClose
|
|
onClose={closePlaybackMenu}
|
|
>
|
|
{PLAYBACK_RATES.map((rate) => (
|
|
// eslint-disable-next-line react/jsx-no-bind
|
|
<MenuItem disabled={playbackRate === rate} onClick={() => onPlaybackRateChange(rate)}>
|
|
{`${rate}x`}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function renderTime(currentTime: number, duration: number) {
|
|
return (
|
|
<div className="player-time">
|
|
{`${formatMediaDuration(currentTime)} / ${formatMediaDuration(duration)}`}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderFileSize(loadedPercent: number, totalSize: number) {
|
|
return (
|
|
<div className="player-file-size">
|
|
{`${formatFileSize(totalSize * loadedPercent)} / ${formatFileSize(totalSize)}`}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderSeekLine(
|
|
currentTime: number, duration: number, bufferedRanges: BufferedRange[], seekerRef: React.RefObject<HTMLDivElement>,
|
|
) {
|
|
const percentagePlayed = (currentTime / duration) * 100;
|
|
|
|
return (
|
|
<div className="player-seekline" ref={seekerRef}>
|
|
<div className="player-seekline-track">
|
|
{bufferedRanges.map(({ start, end }) => (
|
|
<div
|
|
className="player-seekline-buffered"
|
|
style={`left: ${start * 100}%; right: ${100 - end * 100}%`}
|
|
/>
|
|
))}
|
|
<div
|
|
className="player-seekline-played"
|
|
style={`width: ${percentagePlayed || 0}%`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default VideoPlayerControls;
|