[Perf] Introduce Optimized Video component

This commit is contained in:
Alexander Zinchuk 2022-10-17 17:35:06 +02:00
parent 7d714a9b26
commit 04dee2ce7b
13 changed files with 119 additions and 176 deletions

View File

@ -32,8 +32,8 @@ import useMedia from '../../hooks/useMedia';
import useShowTransition from '../../hooks/useShowTransition';
import useLang from '../../hooks/useLang';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useVideoAutoPause from '../middle/message/hooks/useVideoAutoPause';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import OptimizedVideo from '../ui/OptimizedVideo';
import './Avatar.scss';
@ -79,8 +79,6 @@ const Avatar: FC<OwnProps> = ({
const { loadFullUser } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
const videoLoopCountRef = useRef(0);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const isDeleted = user && isDeletedUser(user);
@ -117,24 +115,14 @@ const Avatar: FC<OwnProps> = ({
const { transitionClassNames } = useShowTransition(hasBlobUrl, undefined, hasBlobUrl, 'slow');
const { handlePlaying } = useVideoAutoPause(videoRef, shouldPlayVideo);
useVideoCleanup(videoRef, [shouldPlayVideo]);
const handleVideoEnded = useCallback((e) => {
const video = e.currentTarget;
if (!videoBlobUrl) return;
useEffect(() => {
const video = videoRef.current;
if (!video || !videoBlobUrl) return undefined;
const returnToStart = () => {
videoLoopCountRef.current += 1;
if (videoLoopCountRef.current >= LOOP_COUNT || noLoop) {
video.style.display = 'none';
} else {
video.play();
}
};
video.addEventListener('ended', returnToStart);
return () => video.removeEventListener('ended', returnToStart);
videoLoopCountRef.current += 1;
if (videoLoopCountRef.current >= LOOP_COUNT || noLoop) {
video.style.display = 'none';
}
}, [noLoop, videoBlobUrl]);
const userId = user?.id;
@ -165,15 +153,15 @@ const Avatar: FC<OwnProps> = ({
decoding="async"
/>
{shouldPlayVideo && (
<video
ref={videoRef}
<OptimizedVideo
canPlay
src={videoBlobUrl}
className={buildClassName(cn.media, 'avatar-media')}
muted
autoPlay
disablePictureInPicture
playsInline
onPlaying={handlePlaying}
onEnded={handleVideoEnded}
/>
)}
</>

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useMemo, useRef, useState,
memo, useCallback, useEffect, useRef, useState,
} from '../../lib/teact/teact';
import { getGlobal } from '../../global';
@ -8,7 +8,6 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import renderText from './helpers/renderText';
import safePlay from '../../util/safePlay';
import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme';
import buildClassName from '../../util/buildClassName';
@ -21,6 +20,7 @@ import useThumbnail from '../../hooks/useThumbnail';
import useCustomEmoji from './hooks/useCustomEmoji';
import AnimatedSticker from './AnimatedSticker';
import OptimizedVideo from '../ui/OptimizedVideo';
import styles from './CustomEmoji.module.scss';
@ -77,36 +77,16 @@ const CustomEmoji: FC<OwnProps> = ({
useEnsureCustomEmoji(documentId);
useEffect(() => {
if (!customEmoji?.isVideo) return;
const video = ref.current?.querySelector('video');
if (!video || isIntersecting === !video.paused) return;
const handleVideoEnded = useCallback((e) => {
if (!loopLimit) return;
if (isIntersecting) {
safePlay(video);
} else {
video.pause();
loopCountRef.current += 1;
if (loopCountRef.current >= loopLimit) {
setShouldLoop(false);
e.currentTarget.currentTime = 0;
}
}, [customEmoji, isIntersecting]);
useEffect(() => {
if (!loopLimit) return undefined;
const video = ref.current?.querySelector('video');
if (!mediaData || !video) return undefined;
const returnToStart = () => {
loopCountRef.current += 1;
if (loopCountRef.current >= loopLimit) {
setShouldLoop(false);
video.currentTime = 0;
} else {
video.play();
}
};
video.addEventListener('ended', returnToStart);
return () => video.removeEventListener('ended', returnToStart);
}, [loopLimit, mediaData]);
}, [loopLimit]);
const handleStickerLoop = useCallback(() => {
if (!loopLimit) return;
@ -117,7 +97,7 @@ const CustomEmoji: FC<OwnProps> = ({
}
}, [loopLimit]);
const content = useMemo(() => {
function renderContent() {
if (!customEmoji || (!thumbDataUri && !mediaData)) {
return (children && renderText(children, ['emoji']));
}
@ -136,14 +116,15 @@ const CustomEmoji: FC<OwnProps> = ({
if (customEmoji.isVideo) {
return (
<video
<OptimizedVideo
canPlay={isIntersecting}
className={styles.media}
src={mediaData}
playsInline
muted
loop={!loopLimit}
autoPlay={isIntersecting}
src={mediaData}
disablePictureInPicture
onEnded={handleVideoEnded}
/>
);
}
@ -161,10 +142,7 @@ const CustomEmoji: FC<OwnProps> = ({
onLoop={handleStickerLoop}
/>
);
}, [
children, customColor, customEmoji, handleStickerLoop, isIntersecting, loopLimit, mediaData, shouldLoop,
thumbDataUri,
]);
}
return (
<div
@ -179,7 +157,7 @@ const CustomEmoji: FC<OwnProps> = ({
)}
onClick={onClick}
>
{content}
{renderContent()}
</div>
);
};

View File

@ -13,7 +13,6 @@ import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import useMedia from '../../hooks/useMedia';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import useBuffering from '../../hooks/useBuffering';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useLang from '../../hooks/useLang';
@ -24,6 +23,7 @@ import Spinner from '../ui/Spinner';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import OptimizedVideo from '../ui/OptimizedVideo';
import './GifButton.scss';
@ -48,8 +48,6 @@ const GifButton: FC<OwnProps> = ({
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
const lang = useLang();
@ -65,8 +63,6 @@ const GifButton: FC<OwnProps> = ({
const shouldRenderSpinner = loadAndPlay && !isBuffered;
const isVideoReady = loadAndPlay && isBuffered;
useVideoCleanup(videoRef, [shouldRenderVideo]);
const {
isContextMenuOpen, contextMenuPosition,
handleBeforeContextMenu, handleContextMenu,
@ -177,8 +173,9 @@ const GifButton: FC<OwnProps> = ({
/>
)}
{shouldRenderVideo && (
<video
ref={videoRef}
<OptimizedVideo
canPlay
src={videoData}
autoPlay
loop
muted
@ -187,9 +184,7 @@ const GifButton: FC<OwnProps> = ({
preload="none"
// eslint-disable-next-line react/jsx-props-no-spreading
{...bufferingHandlers}
>
<source src={videoData} />
</video>
/>
)}
{shouldRenderSpinner && (
<Spinner color={previewBlobUrl || hasThumbnail ? 'white' : 'black'} />

View File

@ -18,11 +18,9 @@ import buildClassName from '../../util/buildClassName';
import { getFirstLetters } from '../../util/textFormat';
import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang';
import useVideoAutoPause from '../middle/message/hooks/useVideoAutoPause';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import safePlay from '../../util/safePlay';
import Spinner from '../ui/Spinner';
import OptimizedVideo from '../ui/OptimizedVideo';
import './ProfilePhoto.scss';
@ -79,18 +77,11 @@ const ProfilePhoto: FC<OwnProps> = ({
}
useEffect(() => {
if (!videoRef.current) return;
if (!canPlayVideo) {
videoRef.current.pause();
if (videoRef.current && !canPlayVideo) {
videoRef.current.currentTime = 0;
} else {
safePlay(videoRef.current);
}
}, [canPlayVideo]);
const { handlePlaying } = useVideoAutoPause(videoRef, canPlayVideo);
useVideoCleanup(videoRef, []);
const photoHash = getMediaHash('big', 'photo');
const photoBlobUrl = useMedia(photoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
const videoHash = getMediaHash('normal', 'video');
@ -108,16 +99,15 @@ const ProfilePhoto: FC<OwnProps> = ({
} else if (imageSrc) {
if (videoBlobUrl) {
content = (
<video
<OptimizedVideo
canPlay={canPlayVideo}
ref={videoRef}
src={imageSrc}
className="avatar-media"
muted
disablePictureInPicture
autoPlay={canPlayVideo}
loop
playsInline
onPlaying={handlePlaying}
/>
);
} else {

View File

@ -26,6 +26,7 @@ import AnimatedSticker from './AnimatedSticker';
import Button from '../ui/Button';
import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem';
import OptimizedVideo from '../ui/OptimizedVideo';
import './StickerButton.scss';
@ -269,10 +270,10 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<img src={previewBlobUrl} className={previewTransitionClassNames} />
)}
{isVideo && (
<video
<OptimizedVideo
canPlay={canVideoPlay}
className={previewTransitionClassNames}
src={videoBlobUrl}
autoPlay={canVideoPlay}
loop
playsInline
disablePictureInPicture

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../../../lib/teact/teact';
import React, { memo } from '../../../../lib/teact/teact';
import type { ApiThumbnail } from '../../../../api/types';
@ -7,9 +7,9 @@ import useMedia from '../../../../hooks/useMedia';
import buildClassName from '../../../../util/buildClassName';
import useCanvasBlur from '../../../../hooks/useCanvasBlur';
import useMediaTransition from '../../../../hooks/useMediaTransition';
import safePlay from '../../../../util/safePlay';
import DeviceFrame from '../../../../assets/premium/DeviceFrame.svg';
import OptimizedVideo from '../../../ui/OptimizedVideo';
import styles from './PremiumFeaturePreviewVideo.module.scss';
@ -33,19 +33,6 @@ const PremiumFeaturePreviewVideo: FC<OwnProps> = ({
const mediaData = useMedia(`document${videoId}`);
const thumbnailRef = useCanvasBlur(videoThumbnail.dataUri);
const transitionClassNames = useMediaTransition(mediaData);
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isActive) {
safePlay(video);
} else {
video.pause();
}
}, [isActive]);
return (
<div className={styles.root}>
@ -59,14 +46,10 @@ const PremiumFeaturePreviewVideo: FC<OwnProps> = ({
>
<img src={DeviceFrame} alt="" className={styles.frame} />
<canvas ref={thumbnailRef} className={styles.video} />
<video
ref={videoRef}
className={buildClassName(
styles.video,
transitionClassNames,
)}
<OptimizedVideo
canPlay={isActive}
className={buildClassName(styles.video, transitionClassNames)}
src={mediaData}
autoPlay={isActive}
disablePictureInPicture
playsInline
muted

View File

@ -64,6 +64,7 @@ import MessageListContent from './MessageListContent';
import ContactGreeting from './ContactGreeting';
import NoMessages from './NoMessages';
import Skeleton from '../ui/Skeleton';
import OptimizedVideo from '../ui/OptimizedVideo';
import './MessageList.scss';
@ -563,10 +564,10 @@ const MessageList: FC<OwnProps & StateProps> = ({
/>
)}
{botInfoGifUrl && (
<video
<OptimizedVideo
canPlay
src={botInfoGifUrl}
loop
autoPlay
disablePictureInPicture
muted
playsInline

View File

@ -10,6 +10,8 @@ import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMedia from '../../../hooks/useMedia';
import useMediaTransition from '../../../hooks/useMediaTransition';
import OptimizedVideo from '../../ui/OptimizedVideo';
type OwnProps = {
stickerSet: ApiStickerSet;
observeIntersection: ObserveFn;
@ -34,7 +36,7 @@ const StickerSetCover: FC<OwnProps> = ({ stickerSet, observeIntersection }) => {
<div ref={ref} className="sticker-set-cover">
{firstLetters}
{isVideo ? (
<video src={mediaData} className={transitionClassNames} loop autoPlay disablePictureInPicture />
<OptimizedVideo canPlay src={mediaData} className={transitionClassNames} loop disablePictureInPicture />
) : (
<img src={mediaData} className={transitionClassNames} alt="" />
)}

View File

@ -24,11 +24,10 @@ import useShowTransition from '../../../hooks/useShowTransition';
import useMediaTransition from '../../../hooks/useMediaTransition';
import usePrevious from '../../../hooks/usePrevious';
import useBuffering from '../../../hooks/useBuffering';
import useVideoCleanup from '../../../hooks/useVideoCleanup';
import useVideoAutoPause from './hooks/useVideoAutoPause';
import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import ProgressSpinner from '../../ui/ProgressSpinner';
import OptimizedVideo from '../../ui/OptimizedVideo';
import './RoundVideo.scss';
@ -141,21 +140,6 @@ const RoundVideo: FC<OwnProps> = ({
stopPrevious = stopPlaying;
}, [stopPlaying]);
useEffect(() => {
if (!playerRef.current) {
return;
}
if (shouldPlay) {
safePlay(playerRef.current);
} else {
playerRef.current.pause();
}
}, [shouldPlay]);
useVideoAutoPause(playerRef, shouldPlay);
useVideoCleanup(playerRef, [mediaData]);
const handleClick = useCallback(() => {
if (!mediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
@ -212,8 +196,10 @@ const RoundVideo: FC<OwnProps> = ({
{mediaData && (
<div className="video-wrapper">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
<OptimizedVideo
canPlay={shouldPlay}
ref={playerRef}
src={mediaData}
className={videoClassName}
width={ROUND_VIDEO_DIMENSIONS_PX}
height={ROUND_VIDEO_DIMENSIONS_PX}
@ -226,9 +212,7 @@ const RoundVideo: FC<OwnProps> = ({
// eslint-disable-next-line react/jsx-props-no-spreading
{...bufferingHandlers}
onTimeUpdate={isActivated ? handleTimeUpdate : undefined}
>
<source src={mediaData} />
</video>
/>
</div>
)}
<div className="progress" ref={playingProgressRef} />

View File

@ -7,7 +7,6 @@ import { ApiMediaFormat } from '../../../api/types';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
import { getMessageMediaFormat, getMessageMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import safePlay from '../../../util/safePlay';
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global';
@ -20,6 +19,7 @@ import useThumbnail from '../../../hooks/useThumbnail';
import useLang from '../../../hooks/useLang';
import AnimatedSticker from '../../common/AnimatedSticker';
import OptimizedVideo from '../../ui/OptimizedVideo';
import './Sticker.scss';
@ -101,17 +101,6 @@ const Sticker: FC<OwnProps> = ({
onStopEffect?.();
}, [onStopEffect, stopPlayingEffect]);
useEffect(() => {
if (!isVideo || !ref.current) return;
const video = ref.current.querySelector('video');
if (!video) return;
if (shouldPlay) {
safePlay(video);
} else {
video.pause();
}
}, [isVideo, shouldPlay]);
useEffect(() => {
if (hasEffect && shouldPlay && shouldPlayEffect) {
startPlayingEffect();
@ -164,11 +153,11 @@ const Sticker: FC<OwnProps> = ({
/>
)}
{isVideo && canDisplayVideo && isMediaReady && (
<video
<OptimizedVideo
canPlay={shouldPlay}
src={mediaData as string}
width={width}
height={height}
autoPlay={shouldPlay}
playsInline
disablePictureInPicture
loop={shouldLoop}

View File

@ -24,12 +24,11 @@ import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition';
import usePrevious from '../../../hooks/usePrevious';
import useBuffering from '../../../hooks/useBuffering';
import useVideoCleanup from '../../../hooks/useVideoCleanup';
import useMediaTransition from '../../../hooks/useMediaTransition';
import useVideoAutoPause from './hooks/useVideoAutoPause';
import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import ProgressSpinner from '../../ui/ProgressSpinner';
import OptimizedVideo from '../../ui/OptimizedVideo';
export type OwnProps = {
id?: string;
@ -125,15 +124,12 @@ const Video: FC<OwnProps> = ({
setPlayProgress(Math.max(0, e.currentTarget.currentTime - 1));
}, []);
const duration = (videoRef.current?.duration) || video.duration || 0;
const duration = videoRef.current?.duration || video.duration || 0;
const isOwn = isOwnMessage(message);
const isForwarded = isForwardedMessage(message);
const { width, height } = dimensions || calculateVideoDimensions(video, isOwn, isForwarded, noAvatars);
useVideoAutoPause(videoRef, isInline);
useVideoCleanup(videoRef, [isInline]);
const handleClick = useCallback(() => {
if (isUploading) {
if (onCancelUpload) {
@ -145,7 +141,6 @@ const Video: FC<OwnProps> = ({
setIsLoadAllowed((isAllowed) => !isAllowed);
} else if (fullMediaData && !isPlayAllowed) {
setIsPlayAllowed(true);
videoRef.current!.play();
} else if (onClick) {
onClick(message.id);
}
@ -177,12 +172,13 @@ const Video: FC<OwnProps> = ({
draggable={!isProtected}
/>
{isInline && (
<video
<OptimizedVideo
ref={videoRef}
canPlay={isPlayAllowed}
src={fullMediaData}
className="full-media"
width={width}
height={height}
autoPlay={isPlayAllowed}
muted
loop
playsInline
@ -191,9 +187,7 @@ const Video: FC<OwnProps> = ({
draggable={!isProtected}
onTimeUpdate={handleTimeUpdate}
style={aspectRatio}
>
<source src={fullMediaData} />
</video>
/>
)}
{isProtected && <span className="protector" />}
{shouldRenderPlayButton && <i className={buildClassName('icon-large-play', playButtonClassNames)} />}

View File

@ -1,4 +1,4 @@
import { useCallback, useRef } from '../../../../lib/teact/teact';
import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact';
import { fastRaf } from '../../../../util/schedulers';
import safePlay from '../../../../util/safePlay';
@ -6,7 +6,6 @@ import useBackgroundMode from '../../../../hooks/useBackgroundMode';
import useHeavyAnimationCheck from '../../../../hooks/useHeavyAnimationCheck';
export default function useVideoAutoPause(playerRef: { current: HTMLVideoElement | null }, canPlay: boolean) {
const wasPlaying = useRef(playerRef.current?.paused);
const canPlayRef = useRef();
canPlayRef.current = canPlay;
@ -15,23 +14,15 @@ export default function useVideoAutoPause(playerRef: { current: HTMLVideoElement
const freezePlaying = useCallback(() => {
isFrozenRef.current = true;
if (!playerRef.current) {
return;
}
wasPlaying.current = !playerRef.current.paused;
if (wasPlaying.current) {
playerRef.current.pause();
}
playerRef.current?.pause();
}, [playerRef]);
const unfreezePlaying = useCallback(() => {
isFrozenRef.current = false;
if (
playerRef.current && wasPlaying.current && canPlayRef.current
// At this point HTMLVideoElement can be unmounted from the DOM
playerRef.current && canPlayRef.current
// At this point `HTMLVideoElement` can be unmounted from the DOM
&& document.body.contains(playerRef.current)
) {
safePlay(playerRef.current);
@ -46,11 +37,24 @@ export default function useVideoAutoPause(playerRef: { current: HTMLVideoElement
useHeavyAnimationCheck(freezePlaying, unfreezePlaying);
const handlePlaying = useCallback(() => {
if (isFrozenRef.current) {
wasPlaying.current = true;
if (!canPlayRef.current || isFrozenRef.current) {
playerRef.current!.pause();
}
}, [playerRef]);
useEffect(() => {
if (!playerRef.current) {
return;
}
if (canPlay) {
if (!isFrozenRef.current) {
safePlay(playerRef.current);
}
} else {
playerRef.current!.pause();
}
}, [canPlay, playerRef]);
return { handlePlaying };
}

View File

@ -0,0 +1,34 @@
import React, { memo, useRef } from '../../lib/teact/teact';
import useVideoAutoPause from '../middle/message/hooks/useVideoAutoPause';
import useVideoCleanup from '../../hooks/useVideoCleanup';
type OwnProps =
{
canPlay: boolean;
ref?: React.RefObject<HTMLVideoElement>;
}
& React.DetailedHTMLProps<React.VideoHTMLAttributes<HTMLVideoElement>, HTMLVideoElement>;
function OptimizedVideo({
ref,
canPlay,
...restProps
}: OwnProps) {
// eslint-disable-next-line no-null/no-null
const localRef = useRef<HTMLVideoElement>(null);
if (!ref) {
ref = localRef;
}
const { handlePlaying } = useVideoAutoPause(ref, canPlay);
useVideoCleanup(ref, []);
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<video ref={ref} autoPlay {...restProps} onPlaying={handlePlaying} />
);
}
export default memo(OptimizedVideo);