import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useRef, useState, } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { ApiDimensions } from '../../api/types'; import { IS_IOS, IS_TOUCH_ENV, IS_YA_BROWSER } from '../../util/windowEnvironment'; import safePlay from '../../util/safePlay'; import stopEvent from '../../util/stopEvent'; import useBuffering from '../../hooks/useBuffering'; import useFullscreen from '../../hooks/useFullscreen'; import usePictureInPicture from '../../hooks/usePictureInPicture'; import useShowTransition from '../../hooks/useShowTransition'; import useVideoCleanup from '../../hooks/useVideoCleanup'; import useAppLayout from '../../hooks/useAppLayout'; import Button from '../ui/Button'; import ProgressSpinner from '../ui/ProgressSpinner'; import VideoPlayerControls from './VideoPlayerControls'; import './VideoPlayer.scss'; type OwnProps = { url?: string; isGif?: boolean; posterData?: string; posterSize?: ApiDimensions; loadProgress?: number; fileSize: number; isMediaViewerOpen?: boolean; noPlay?: boolean; volume: number; isMuted: boolean; isHidden?: boolean; playbackRate: number; isProtected?: boolean; areControlsVisible: boolean; shouldCloseOnClick?: boolean; isForceMobileVersion?: boolean; toggleControls: (isVisible: boolean) => void; onClose: (e: React.MouseEvent) => void; isClickDisabled?: boolean; }; const MAX_LOOP_DURATION = 30; // Seconds const VideoPlayer: FC = ({ url, isGif, posterData, posterSize, loadProgress, fileSize, isMediaViewerOpen, noPlay, volume, isMuted, playbackRate, onClose, isForceMobileVersion, toggleControls, areControlsVisible, shouldCloseOnClick, isProtected, isClickDisabled, }) => { const { setMediaViewerVolume, setMediaViewerMuted, setMediaViewerPlaybackRate, setMediaViewerHidden, } = getActions(); // eslint-disable-next-line no-null/no-null const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(!IS_TOUCH_ENV || !IS_IOS); const [currentTime, setCurrentTime] = useState(0); const [isFullscreen, setFullscreen, exitFullscreen] = useFullscreen(videoRef, setIsPlaying); const { isMobile } = useAppLayout(); const handleEnterFullscreen = useCallback(() => { // Yandex browser doesn't support PIP when video is hidden if (IS_YA_BROWSER) return; setMediaViewerHidden({ isHidden: true }); }, [setMediaViewerHidden]); const handleLeaveFullscreen = useCallback(() => { if (IS_YA_BROWSER) return; setMediaViewerHidden({ isHidden: false }); }, [setMediaViewerHidden]); const [ isPictureInPictureSupported, enterPictureInPicture, isInPictureInPicture, ] = usePictureInPicture(videoRef, handleEnterFullscreen, handleLeaveFullscreen); const handleVideoMove = useCallback(() => { toggleControls(true); }, [toggleControls]); const handleVideoLeave = useCallback((e) => { const bounds = videoRef.current?.getBoundingClientRect(); if (!bounds) return; if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) { toggleControls(false); } }, [toggleControls]); const { isBuffered, bufferedRanges, bufferingHandlers, bufferedProgress, } = useBuffering(); const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, } = useShowTransition(!isBuffered, undefined, undefined, 'slow'); const { shouldRender: shouldRenderPlayButton, transitionClassNames: playButtonClassNames, } = useShowTransition(IS_IOS && !isPlaying && !shouldRenderSpinner, undefined, undefined, 'slow'); useEffect(() => { if (noPlay || !isMediaViewerOpen) { videoRef.current!.pause(); } else if (url && !IS_TOUCH_ENV) { // Chrome does not automatically start playing when `url` becomes available (even with `autoPlay`), // so we force it here. Contrary, iOS does not allow to call `play` without mouse event, // so we need to use `autoPlay` instead to allow pre-buffering. safePlay(videoRef.current!); } }, [noPlay, isMediaViewerOpen, url, setMediaViewerMuted]); useEffect(() => { if (videoRef.current!.currentTime === videoRef.current!.duration) { setCurrentTime(0); setIsPlaying(false); } else { setCurrentTime(videoRef.current!.currentTime); } }, [currentTime]); useEffect(() => { videoRef.current!.volume = volume; }, [volume]); useEffect(() => { videoRef.current!.playbackRate = playbackRate; }, [playbackRate]); const togglePlayState = useCallback((e: React.MouseEvent | KeyboardEvent) => { e.stopPropagation(); if (isPlaying) { videoRef.current!.pause(); setIsPlaying(false); } else { safePlay(videoRef.current!); setIsPlaying(true); } }, [isPlaying]); const handleClick = useCallback((e: React.MouseEvent) => { if (isClickDisabled) { return; } if (shouldCloseOnClick) { onClose(e); } else { togglePlayState(e); } }, [onClose, shouldCloseOnClick, togglePlayState, isClickDisabled]); useVideoCleanup(videoRef, []); const handleTimeUpdate = useCallback((e: React.SyntheticEvent) => { setCurrentTime(e.currentTarget.currentTime); }, []); const handleEnded = useCallback(() => { setCurrentTime(0); setIsPlaying(false); toggleControls(true); }, [toggleControls]); const handleFullscreenChange = useCallback(() => { if (isFullscreen && exitFullscreen) { exitFullscreen(); } else if (!isFullscreen && setFullscreen) { setFullscreen(); } }, [exitFullscreen, isFullscreen, setFullscreen]); const handleSeek = useCallback((position: number) => { videoRef.current!.currentTime = position; }, []); const handleVolumeChange = useCallback((newVolume: number) => { setMediaViewerVolume({ volume: newVolume / 100 }); }, [setMediaViewerVolume]); const handleVolumeMuted = useCallback(() => { // Browser requires explicit user interaction to keep video playing after unmuting videoRef.current!.muted = !videoRef.current!.muted; setMediaViewerMuted({ isMuted: !isMuted }); }, [isMuted, setMediaViewerMuted]); const handlePlaybackRateChange = useCallback((newPlaybackRate: number) => { setMediaViewerPlaybackRate({ playbackRate: newPlaybackRate }); }, [setMediaViewerPlaybackRate]); useEffect(() => { if (!isMediaViewerOpen) return undefined; const togglePayingStateBySpace = (e: KeyboardEvent) => { if ((e.key === 'Enter' || e.key === ' ') && !isInPictureInPicture) { e.preventDefault(); togglePlayState(e); } }; document.addEventListener('keydown', togglePayingStateBySpace, false); return () => { document.removeEventListener('keydown', togglePayingStateBySpace, false); }; }, [togglePlayState, isMediaViewerOpen, isInPictureInPicture]); const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`; const videoStyle = `background-image: url(${posterData})`; const shouldToggleControls = !IS_TOUCH_ENV && !isForceMobileVersion; const duration = videoRef.current?.duration || 0; return ( // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
{/* eslint-disable-next-line jsx-a11y/media-has-caption */} {isProtected && (
)}
{shouldRenderPlayButton && ( )} {shouldRenderSpinner && (
{!isBuffered &&
Buffering...
}
)} {!isGif && !shouldRenderSpinner && ( )}
); }; export default memo(VideoPlayer);