import type { FC } from '../../lib/teact/teact'; import React, { memo, 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 { clamp } from '../../util/math'; import useLastCallback from '../../hooks/useLastCallback'; 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 useCurrentTimeSignal from './hooks/useCurrentTimeSignal'; import useControlsSignal from './hooks/useControlsSignal'; import useVideoWaitingSignal from './hooks/useVideoWaitingSignal'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; 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; isPreviewDisabled?: boolean; isMediaViewerOpen?: boolean; noPlay?: boolean; volume: number; isMuted: boolean; isHidden?: boolean; playbackRate: number; isProtected?: boolean; shouldCloseOnClick?: boolean; isForceMobileVersion?: boolean; onClose: (e: React.MouseEvent) => void; isClickDisabled?: boolean; }; const MAX_LOOP_DURATION = 30; // Seconds const MIN_READY_STATE = 4; const REWIND_STEP = 5; // Seconds const VideoPlayer: FC = ({ url, isGif, posterData, posterSize, loadProgress, fileSize, isMediaViewerOpen, noPlay, volume, isMuted, playbackRate, onClose, isForceMobileVersion, shouldCloseOnClick, isProtected, isClickDisabled, isPreviewDisabled, }) => { 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 [isFullscreen, setFullscreen, exitFullscreen] = useFullscreen(videoRef, setIsPlaying); const { isMobile } = useAppLayout(); const duration = videoRef.current?.duration || 0; const isLooped = isGif || duration <= MAX_LOOP_DURATION; const handleEnterFullscreen = useLastCallback(() => { // Yandex browser doesn't support PIP when video is hidden if (IS_YA_BROWSER) return; setMediaViewerHidden({ isHidden: true }); }); const handleLeaveFullscreen = useLastCallback(() => { if (IS_YA_BROWSER) return; setMediaViewerHidden({ isHidden: false }); }); const [ isPictureInPictureSupported, enterPictureInPicture, isInPictureInPicture, ] = usePictureInPicture(videoRef, handleEnterFullscreen, handleLeaveFullscreen); const [, toggleControls, lockControls] = useControlsSignal(); const handleVideoMove = useLastCallback(() => { toggleControls(true); }); const handleVideoLeave = useLastCallback((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); } }); const { isReady, isBuffered, bufferedRanges, bufferingHandlers, bufferedProgress, } = useBuffering(); const isUnsupported = useUnsupportedMedia(videoRef, undefined, !url); const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, } = useShowTransition(!isBuffered && !isUnsupported, undefined, undefined, 'slow'); const { shouldRender: shouldRenderPlayButton, transitionClassNames: playButtonClassNames, } = useShowTransition(IS_IOS && !isPlaying && !shouldRenderSpinner && !isUnsupported, undefined, undefined, 'slow'); useEffect(() => { lockControls(shouldRenderSpinner); }, [lockControls, shouldRenderSpinner]); useEffect(() => { if (noPlay || !isMediaViewerOpen || isUnsupported) { 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, isUnsupported]); useEffect(() => { videoRef.current!.volume = volume; }, [volume]); useEffect(() => { videoRef.current!.playbackRate = playbackRate; }, [playbackRate]); const togglePlayState = useLastCallback((e: React.MouseEvent | KeyboardEvent) => { e.stopPropagation(); if (isPlaying) { videoRef.current!.pause(); setIsPlaying(false); } else { safePlay(videoRef.current!); setIsPlaying(true); } }); const handleClick = useLastCallback((e: React.MouseEvent) => { if (isClickDisabled) { return; } if (shouldCloseOnClick) { onClose(e); } else { togglePlayState(e); } }); useVideoCleanup(videoRef, []); const [, setCurrentTime] = useCurrentTimeSignal(); const [, setIsVideoWaiting] = useVideoWaitingSignal(); const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { const video = e.currentTarget; if (video.readyState >= MIN_READY_STATE) { setIsVideoWaiting(false); setCurrentTime(video.currentTime); } if (!isLooped && video.currentTime === video.duration) { setCurrentTime(0); setIsPlaying(false); } }); const handleEnded = useLastCallback(() => { if (isLooped) return; setCurrentTime(0); setIsPlaying(false); toggleControls(true); }); const handleFullscreenChange = useLastCallback(() => { if (isFullscreen && exitFullscreen) { exitFullscreen(); } else if (!isFullscreen && setFullscreen) { setFullscreen(); } }); const handleSeek = useLastCallback((position: number) => { videoRef.current!.currentTime = position; }); const handleVolumeChange = useLastCallback((newVolume: number) => { setMediaViewerVolume({ volume: newVolume / 100 }); }); const handleVolumeMuted = useLastCallback(() => { // Browser requires explicit user interaction to keep video playing after unmuting videoRef.current!.muted = !videoRef.current!.muted; setMediaViewerMuted({ isMuted: !isMuted }); }); const handlePlaybackRateChange = useLastCallback((newPlaybackRate: number) => { setMediaViewerPlaybackRate({ playbackRate: newPlaybackRate }); }); useEffect(() => { if (!isMediaViewerOpen) return undefined; const rewind = (dir: number) => { if (!isFullscreen) return; const video = videoRef.current!; const newTime = clamp(video.currentTime + dir * REWIND_STEP, 0, video.duration); if (Number.isFinite(newTime)) { video.currentTime = newTime; } }; const handleKeyDown = (e: KeyboardEvent) => { if (isInPictureInPicture) return; switch (e.key) { case ' ': case 'Enter': e.preventDefault(); togglePlayState(e); break; case 'Left': // IE/Edge specific value case 'ArrowLeft': e.preventDefault(); rewind(-1); break; case 'Right': // IE/Edge specific value case 'ArrowRight': e.preventDefault(); rewind(1); break; } }; document.addEventListener('keydown', handleKeyDown, false); return () => { document.removeEventListener('keydown', handleKeyDown, false); }; }, [togglePlayState, isMediaViewerOpen, isFullscreen, isInPictureInPicture]); const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`; const videoStyle = `background-image: url(${posterData})`; const shouldToggleControls = !IS_TOUCH_ENV && !isForceMobileVersion; 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 && !isUnsupported && ( )}
); }; export default memo(VideoPlayer);