2026-03-31 11:28:36 +02:00

372 lines
12 KiB
TypeScript

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<T> = {
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<HTMLElement>) => void;
onCancelUpload?: (arg: T) => void;
};
type StateProps = {
needsAgeVerification?: boolean;
};
const Video = <T,>({
id,
video,
isOwn,
isInWebPage,
noAvatars,
canAutoLoad,
canAutoPlay,
uploadProgress,
forcedWidth,
dimensions,
asForwarded,
isDownloading,
isProtected,
className,
lastPlaybackTimestamp,
clickArg,
isMediaNsfw,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onClick,
onCancelUpload,
needsAgeVerification,
}: OwnProps<T> & StateProps) => {
const { cancelMediaDownload, updateContentSettings, openAgeVerificationModal } = getActions();
const ref = useRef<HTMLDivElement>();
const videoRef = useRef<HTMLVideoElement>();
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<HTMLImageElement>({
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<number>(0);
const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
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<HTMLElement, 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<HTMLElement, 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 (
<div
ref={ref}
id={id}
className={componentClassName}
style={style}
onClick={isUploading ? undefined : (e) => handleClick(e)}
>
{withBlurredBackground && (
<canvas ref={blurredBackgroundRef} className="thumbnail blurred-bg" />
)}
{isInline && (
<OptimizedVideo
ref={videoRef}
src={fullMediaData}
className={buildClassName('full-media', withBlurredBackground && 'with-blurred-bg')}
canPlay={isPlayAllowed && isIntersectingForPlaying && !isUnsupported}
defaultMuted
muted
loop
playsInline
disablePictureInPicture
draggable={!isProtected}
onTimeUpdate={handleTimeUpdate}
onReady={markPlayerReady}
style={forcedWidth ? `width: ${forcedWidth}px` : undefined}
/>
)}
{previewBlobUrl && (
<img
ref={previewRef}
src={previewBlobUrl}
className={buildClassName('thumbnail', withBlurredBackground && 'with-blurred-bg')}
alt=""
style={forcedWidth ? `width: ${forcedWidth}px;` : undefined}
draggable={!isProtected}
/>
)}
{hasThumb && !isPreviewPreloaded && (
<canvas ref={thumbRef} className="thumbnail" />
)}
{isProtected && <span className="protector" />}
<Icon ref={playButtonRef} name="large-play" />
<MediaSpoiler
isVisible={isSpoilerShown}
withAnimation
thumbDataUri={thumbDataUri}
isNsfw={isMediaNsfw}
width={width}
height={height}
className="media-spoiler"
/>
{shouldRenderSpinner && (
<div ref={spinnerRef} className="media-loading">
<ProgressSpinner
progress={transferProgress}
onClick={handleClickOnSpinner}
/>
</div>
)}
{!isLoadAllowed && !fullMediaData && (
<Icon name="download" />
)}
{shouldRenderTransferProgress ? (
<span ref={transferProgressRef} className="message-transfer-progress">
{(isUploading || isDownloading) ? `${Math.round(transferProgress * 100)}%` : '...'}
</span>
) : (
<div className="message-media-duration">
{!isPaidPreview && video.isGif ? 'GIF' : formatMediaDuration(Math.max(duration - playProgress, 0))}
{isUnsupported && <Icon name="message-failed" className="playback-failed" />}
</div>
)}
{Boolean(lastPlaybackTimestamp) && (
<div
className="message-media-last-progress"
style={`--_progress: ${Math.floor((lastPlaybackTimestamp / duration) * 100)}%`}
/>
)}
<SensitiveContentConfirmModal
isOpen={isNsfwModalOpen}
onClose={closeNsfwModal}
shouldAlwaysShow={shouldAlwaysShowNsfw}
onAlwaysShowChanged={setShouldAlwaysShowNsfw}
confirmHandler={handleNsfwConfirm}
/>
</div>
);
};
export default memo(withGlobal((global): Complete<StateProps> => {
const appConfig = global.appConfig;
const needsAgeVerification = appConfig.needAgeVideoVerification;
return {
needsAgeVerification,
};
})(Video));