import type React from '../../../lib/teact/teact'; import { memo, useEffect, useRef, useState } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiMediaExtendedPreview, ApiVideo } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { IMediaDimensions } from './helpers/calculateAlbumLayout'; import { getMediaFormat, getMediaThumbUri, getMediaTransferState, getVideoMediaHash, } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import { formatMediaDuration } from '../../../util/dates/dateFormat'; import * as mediaLoader from '../../../util/mediaLoader'; import { calculateExtendedPreviewDimensions, calculateVideoDimensions } from '../../common/helpers/mediaDimensions'; import { MIN_MEDIA_HEIGHT } from './helpers/mediaDimensions'; import useUnsupportedMedia from '../../../hooks/media/useUnsupportedMedia'; import useAppLayout from '../../../hooks/useAppLayout'; import useFlag from '../../../hooks/useFlag'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; import useMedia from '../../../hooks/useMedia'; import useMediaTransition from '../../../hooks/useMediaTransition'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; import useShowTransition from '../../../hooks/useShowTransition'; import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef'; import Icon from '../../common/icons/Icon'; import MediaSpoiler from '../../common/MediaSpoiler'; import SensitiveContentConfirmModal from '../../common/SensitiveContentConfirmModal'; import OptimizedVideo from '../../ui/OptimizedVideo'; import ProgressSpinner from '../../ui/ProgressSpinner'; export type OwnProps = { id?: string; video: ApiVideo | ApiMediaExtendedPreview; lastPlaybackTimestamp?: number; isOwn?: boolean; isInWebPage?: boolean; noAvatars?: boolean; canAutoLoad?: boolean; canAutoPlay?: boolean; uploadProgress?: number; forcedWidth?: number; dimensions?: IMediaDimensions; asForwarded?: boolean; isDownloading?: boolean; isProtected?: boolean; className?: string; clickArg?: T; isMediaNsfw?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; onClick?: (arg: T, e: React.MouseEvent) => void; onCancelUpload?: (arg: T) => void; }; type StateProps = { needsAgeVerification?: boolean; }; const Video = ({ id, video, isOwn, isInWebPage, noAvatars, canAutoLoad, canAutoPlay, uploadProgress, forcedWidth, dimensions, asForwarded, isDownloading, isProtected, className, lastPlaybackTimestamp, clickArg, isMediaNsfw, observeIntersectionForLoading, observeIntersectionForPlaying, onClick, onCancelUpload, needsAgeVerification, }: OwnProps & StateProps) => { const { cancelMediaDownload, updateContentSettings, openAgeVerificationModal } = getActions(); const ref = useRef(); const videoRef = useRef(); const [isNsfwModalOpen, openNsfwModal, closeNsfwModal] = useFlag(); const [shouldAlwaysShowNsfw, setShouldAlwaysShowNsfw] = useState(false); const isPaidPreview = video.mediaType === 'extendedMediaPreview'; const localBlobUrl = !isPaidPreview ? video.blobUrl : undefined; const shouldShowSpoiler = isPaidPreview || video.isSpoiler || isMediaNsfw; const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(shouldShowSpoiler); useEffect(() => { if (shouldShowSpoiler) { showSpoiler(); } else { hideSpoiler(); } }, [shouldShowSpoiler]); const handleNsfwConfirm = useLastCallback(() => { closeNsfwModal(); hideSpoiler(); if (shouldAlwaysShowNsfw) { updateContentSettings({ isSensitiveEnabled: true }); } }); const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading); const isIntersectingForPlaying = ( useIsIntersecting(ref, observeIntersectionForPlaying) && isIntersectingForLoading ); const wasIntersectedRef = useRef(isIntersectingForLoading); if (isIntersectingForPlaying && !wasIntersectedRef.current) { wasIntersectedRef.current = true; } const { isMobile } = useAppLayout(); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading && !isPaidPreview); const [isPlayAllowed, setIsPlayAllowed] = useState(Boolean(canAutoPlay && !isSpoilerShown)); const fullMediaHash = !isPaidPreview ? getVideoMediaHash(video, 'inline') : undefined; const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash))); const { mediaData, loadProgress } = useMediaWithLoadProgress( fullMediaHash, !shouldLoad, !isPaidPreview ? getMediaFormat(video, 'inline') : undefined, ); const fullMediaData = localBlobUrl || mediaData; const [isPlayerReady, markPlayerReady] = useFlag(); const thumbDataUri = getMediaThumbUri(video); const hasThumb = Boolean(thumbDataUri); const withBlurredBackground = Boolean(forcedWidth); const isInline = fullMediaData && wasIntersectedRef.current; const isUnsupported = useUnsupportedMedia(videoRef, true, !isInline); const previewMediaHash = !isPaidPreview ? getVideoMediaHash(video, 'preview') : undefined; const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash))); const canLoadPreview = isIntersectingForLoading; const previewBlobUrl = useMedia(previewMediaHash, !canLoadPreview); const shouldHidePreview = isPlayerReady && !isUnsupported; const { ref: previewRef } = useMediaTransition({ hasMediaData: Boolean((hasThumb || previewBlobUrl) && !shouldHidePreview), }); const noThumb = Boolean(!hasThumb || previewBlobUrl || isPlayerReady); const thumbRef = useBlurredMediaThumbRef(video, noThumb); useMediaTransition({ ref: thumbRef, hasMediaData: !noThumb }); const blurredBackgroundRef = useBlurredMediaThumbRef(video, !withBlurredBackground); const { loadProgress: downloadProgress } = useMediaWithLoadProgress( !isPaidPreview ? getVideoMediaHash(video, 'download') : undefined, !isDownloading, !isPaidPreview ? getMediaFormat(video, 'download') : undefined, ); const { isUploading, isTransferring, transferProgress } = getMediaTransferState( uploadProgress || (isDownloading ? downloadProgress : loadProgress), (shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading, uploadProgress !== undefined, ); const wasLoadDisabled = usePreviousDeprecated(isLoadAllowed) === false; const { ref: spinnerRef, shouldRender: shouldRenderSpinner, } = useShowTransition({ isOpen: isTransferring && !isUnsupported, noMountTransition: wasLoadDisabled, withShouldRender: true, }); const { ref: playButtonRef, } = useShowTransition({ isOpen: Boolean((isLoadAllowed || fullMediaData) && !isPlayAllowed && !shouldRenderSpinner), }); const { ref: transferProgressRef, shouldRender: shouldRenderTransferProgress, } = useShowTransition({ isOpen: isTransferring && (!isUnsupported || isDownloading), noMountTransition: wasLoadDisabled, withShouldRender: true, }); const [playProgress, setPlayProgress] = useState(0); const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { setPlayProgress(Math.max(0, e.currentTarget.currentTime - 1)); }); const duration = (Number.isFinite(videoRef.current?.duration) && !isUnsupported ? videoRef.current?.duration : video.duration) || 0; const { width, height, } = dimensions || ( isPaidPreview ? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) : calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) ); const handleClick = useLastCallback((e: React.MouseEvent, isFromSpinner?: boolean) => { if (isUploading) { onCancelUpload?.(clickArg!); return; } if (!isPaidPreview && isDownloading) { cancelMediaDownload({ media: video }); return; } if (!fullMediaData) { setIsLoadAllowed((isAllowed) => !isAllowed); return; } if (fullMediaData && !isPlayAllowed) { setIsPlayAllowed(true); } if (isSpoilerShown) { if (isMediaNsfw) { if (needsAgeVerification) { openAgeVerificationModal(); return; } openNsfwModal(); return; } hideSpoiler(); return; } if (isFromSpinner && shouldLoad && !isPlayerReady && !isFullMediaPreloaded) { setIsLoadAllowed(false); e.stopPropagation(); return; } onClick?.(clickArg!, e); }); const handleClickOnSpinner = useLastCallback( (e: React.MouseEvent) => { handleClick(e, true); }, ); const componentClassName = buildClassName( 'media-inner dark', !isUploading && 'interactive', height < MIN_MEDIA_HEIGHT && 'fix-min-height', className, ); const dimensionsStyle = dimensions ? ` width: ${width}px; left: ${dimensions.x}px; top: ${dimensions.y}px;` : ''; const style = `height: ${height}px;${dimensionsStyle}`; return (
handleClick(e)} > {withBlurredBackground && ( )} {isInline && ( )} {previewBlobUrl && ( )} {hasThumb && !isPreviewPreloaded && ( )} {isProtected && } {shouldRenderSpinner && (
)} {!isLoadAllowed && !fullMediaData && ( )} {shouldRenderTransferProgress ? ( {(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'} ) : (
{!isPaidPreview && video.isGif ? 'GIF' : formatMediaDuration(Math.max(duration - playProgress, 0))} {isUnsupported && }
)} {Boolean(lastPlaybackTimestamp) && (
)}
); }; export default memo(withGlobal((global): Complete => { const appConfig = global.appConfig; const needsAgeVerification = appConfig.needAgeVideoVerification; return { needsAgeVerification, }; })(Video));