TelegramPWA/src/components/mediaViewer/VideoPlayerControls.tsx
2023-04-15 13:54:30 +02:00

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;