TelegramPWA/src/components/mediaViewer/VideoPlayerControls.tsx
2025-11-22 12:54:16 +01:00

278 lines
7.9 KiB
TypeScript

import type { FC } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact';
import {
memo, useEffect, useLayoutEffect,
useMemo,
useRef,
useSignal,
} from '../../lib/teact/teact';
import type { ApiDimensions } from '../../api/types';
import type { BufferedRange } from '../../hooks/useBuffering';
import type { IconName } from '../../types/icons';
import { IS_IOS, IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import useAppLayout from '../../hooks/useAppLayout';
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useDerivedState from '../../hooks/useDerivedState';
import useFlag from '../../hooks/useFlag';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import useControlsSignal from './hooks/useControlsSignal';
import AnimatedFileSize from '../common/AnimatedFileSize';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import RangeSlider from '../ui/RangeSlider';
import SeekLine from './SeekLine';
import './VideoPlayerControls.scss';
type OwnProps = {
url?: string;
bufferedRanges: BufferedRange[];
bufferedProgress: number;
duration: number;
isReady: boolean;
fileSize: number;
isForceMobileVersion?: boolean;
isPlaying: boolean;
isFullscreenSupported: boolean;
isPictureInPictureSupported: boolean;
isFullscreen: boolean;
isPreviewDisabled?: boolean;
isBuffered: boolean;
volume: number;
isMuted: boolean;
playbackRate: number;
posterSize?: ApiDimensions;
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;
onSeekingChange: (isSeeking: boolean) => 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> = ({
url,
bufferedRanges,
bufferedProgress,
duration,
isReady,
fileSize,
isForceMobileVersion,
isPlaying,
isFullscreenSupported,
isFullscreen,
isBuffered,
isPreviewDisabled,
volume,
isMuted,
playbackRate,
posterSize,
onChangeFullscreen,
onVolumeClick,
onVolumeChange,
onPlaybackRateChange,
isPictureInPictureSupported,
onPictureInPictureChange,
onPlayPause,
onSeek,
onSeekingChange,
}) => {
const [isPlaybackMenuOpen, openPlaybackMenu, closePlaybackMenu] = useFlag();
const [getCurrentTime] = useCurrentTimeSignal();
const currentTime = useDerivedState(() => Math.trunc(getCurrentTime()), [getCurrentTime]);
const [getIsSeeking, setIsSeeking] = useSignal(false);
const closeTimeoutRef = useRef<number | undefined>();
const { isMobile } = useAppLayout();
const [getIsVisible, setVisibility] = useControlsSignal();
const isVisible = useDerivedState(getIsVisible);
useEffect(() => {
if (!IS_TOUCH_ENV && !isForceMobileVersion) return undefined;
if (!isVisible || !isPlaying || isPlaybackMenuOpen || getIsSeeking()) {
if (closeTimeoutRef.current) window.clearTimeout(closeTimeoutRef.current);
return undefined;
}
closeTimeoutRef.current = window.setTimeout(() => {
setVisibility(false);
}, HIDE_CONTROLS_TIMEOUT_MS);
return () => {
if (closeTimeoutRef.current) window.clearTimeout(closeTimeoutRef.current);
};
}, [isPlaying, isVisible, setVisibility, isPlaybackMenuOpen, getIsSeeking, isForceMobileVersion]);
useLayoutEffect(() => {
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 = useOldLang();
const handleSeek = useLastCallback((position: number) => {
setIsSeeking(false);
onSeek(position);
onSeekingChange(false);
});
const handleSeekStart = useLastCallback(() => {
setIsSeeking(true);
onSeekingChange(true);
});
const volumeIcon: IconName = useMemo(() => {
if (volume === 0 || isMuted) return 'muted';
if (volume < 0.3) return 'volume-1';
if (volume < 0.6) return 'volume-2';
return 'volume-3';
}, [volume, isMuted]);
return (
<div
className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isVisible && 'active')}
onClick={stopEvent}
>
<SeekLine
url={url}
duration={duration}
isReady={isReady}
isPlaying={isPlaying}
isPreviewDisabled={isPreviewDisabled}
posterSize={posterSize}
bufferedRanges={bufferedRanges}
playbackRate={playbackRate}
onSeek={handleSeek}
onSeekStart={handleSeekStart}
isActive={isVisible}
/>
<div className="buttons">
<Button
ariaLabel={lang('AccActionPlay')}
size="tiny"
ripple={!isMobile}
color="translucent-white"
className="play"
round
onClick={onPlayPause}
iconName={isPlaying ? 'pause' : 'play'}
/>
<Button
ariaLabel="Volume"
size="tiny"
color="translucent-white"
className="volume"
round
onClick={onVolumeClick}
iconName={volumeIcon}
/>
{!IS_IOS && (
<RangeSlider bold className="volume-slider" value={isMuted ? 0 : volume * 100} onChange={onVolumeChange} />
)}
{renderTime(currentTime, duration)}
{!isBuffered && (
<div className="player-file-size">
<AnimatedFileSize size={fileSize} progress={bufferedProgress} />
</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}
iconName="pip"
/>
)}
{isFullscreenSupported && (
<Button
ariaLabel="Fullscreen"
size="tiny"
color="translucent-white"
className="fullscreen"
round
onClick={onChangeFullscreen}
iconName={isFullscreen ? 'smallscreen' : 'fullscreen'}
/>
)}
</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) => (
<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>
);
}
export default memo(VideoPlayerControls);