295 lines
8.5 KiB
TypeScript
295 lines
8.5 KiB
TypeScript
import type { FC } from '../../lib/teact/teact';
|
|
import React, {
|
|
useEffect, useRef, useCallback, useMemo,
|
|
} from '../../lib/teact/teact';
|
|
|
|
import type { BufferedRange } from '../../hooks/useBuffering';
|
|
|
|
import { IS_IOS, IS_TOUCH_ENV } from '../../util/windowEnvironment';
|
|
import buildClassName from '../../util/buildClassName';
|
|
import { formatMediaDuration } from '../../util/dateFormat';
|
|
import { formatFileSize } from '../../util/textFormat';
|
|
import { captureEvents } from '../../util/captureEvents';
|
|
import useLang from '../../hooks/useLang';
|
|
import useFlag from '../../hooks/useFlag';
|
|
import useAppLayout from '../../hooks/useAppLayout';
|
|
import useControlsSignal from './hooks/useControlsSignal';
|
|
import useDerivedState from '../../hooks/useDerivedState';
|
|
|
|
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;
|
|
isPlaying: boolean;
|
|
isFullscreenSupported: boolean;
|
|
isPictureInPictureSupported: boolean;
|
|
isFullscreen: boolean;
|
|
isBuffered: boolean;
|
|
volume: number;
|
|
isMuted: boolean;
|
|
playbackRate: number;
|
|
onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
onPictureInPictureChange?: () => void ;
|
|
onVolumeClick: () => void;
|
|
onVolumeChange: (volume: number) => void;
|
|
onPlaybackRateChange: (playbackRate: number) => void;
|
|
onPlayPause: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => 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 = 3000;
|
|
|
|
const VideoPlayerControls: FC<OwnProps> = ({
|
|
bufferedRanges,
|
|
bufferedProgress,
|
|
currentTime,
|
|
duration,
|
|
fileSize,
|
|
isForceMobileVersion,
|
|
isPlaying,
|
|
isFullscreenSupported,
|
|
isFullscreen,
|
|
isBuffered,
|
|
volume,
|
|
isMuted,
|
|
playbackRate,
|
|
onChangeFullscreen,
|
|
onVolumeClick,
|
|
onVolumeChange,
|
|
onPlaybackRateChange,
|
|
isPictureInPictureSupported,
|
|
onPictureInPictureChange,
|
|
onPlayPause,
|
|
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;
|
|
|
|
const { isMobile } = useAppLayout();
|
|
const [getIsVisible, setVisibility] = useControlsSignal();
|
|
const isVisible = useDerivedState(getIsVisible);
|
|
|
|
useEffect(() => {
|
|
if (!IS_TOUCH_ENV && !isForceMobileVersion) return undefined;
|
|
let timeout: number | undefined;
|
|
if (!isVisible || !isPlaying || isSeeking || isPlaybackMenuOpen) {
|
|
if (timeout) window.clearTimeout(timeout);
|
|
return undefined;
|
|
}
|
|
timeout = window.setTimeout(() => {
|
|
setVisibility(false);
|
|
}, HIDE_CONTROLS_TIMEOUT_MS);
|
|
return () => {
|
|
if (timeout) window.clearTimeout(timeout);
|
|
};
|
|
}, [isPlaying, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen, isForceMobileVersion]);
|
|
|
|
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={!isMobile}
|
|
color="translucent-white"
|
|
className="play"
|
|
round
|
|
onClick={onPlayPause}
|
|
>
|
|
<i className={isPlaying ? '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 && (
|
|
<div className="player-file-size">
|
|
{`${formatFileSize(lang, fileSize * bufferedProgress)} / ${formatFileSize(lang, fileSize)}`}
|
|
</div>
|
|
)}
|
|
<div className="spacer" />
|
|
<Button
|
|
ariaLabel="Playback rate"
|
|
size="tiny"
|
|
color="translucent-white"
|
|
className="playback-rate"
|
|
round
|
|
onClick={openPlaybackMenu}
|
|
>
|
|
{`${playbackRate}x`}
|
|
</Button>
|
|
{isPictureInPictureSupported && (
|
|
<Button
|
|
ariaLabel="Picture in picture"
|
|
size="tiny"
|
|
color="translucent-white"
|
|
className="fullscreen"
|
|
round
|
|
onClick={onPictureInPictureChange}
|
|
>
|
|
<i className="icon-pip" />
|
|
</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={buildClassName(
|
|
'playback-rate-menu',
|
|
!isFullscreenSupported && 'no-fullscreen',
|
|
!isPictureInPictureSupported && 'no-pip',
|
|
)}
|
|
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 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;
|