Profile Photo: Progressive loading, fix flickering (#2116)

This commit is contained in:
Alexander Zinchuk 2022-11-10 18:27:49 +04:00
parent b3fe234a71
commit dd3a76f736
4 changed files with 79 additions and 54 deletions

View File

@ -185,7 +185,6 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
chat={chat} chat={chat}
photo={photo} photo={photo}
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
isFirstPhoto={isFirst}
canPlayVideo={Boolean(isActive && canPlayVideo)} canPlayVideo={Boolean(isActive && canPlayVideo)}
onClick={handleProfilePhotoClick} onClick={handleProfilePhotoClick}
/> />

View File

@ -37,4 +37,20 @@
&.saved-messages { &.saved-messages {
font-size: 20rem; font-size: 20rem;
} }
.thumb {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
}
.thumb, .avatar-media {
// @optimization
&:not(.shown) {
display: block !important;
}
}
} }

View File

@ -2,8 +2,8 @@ import React, { memo, useEffect, useRef } from '../../lib/teact/teact';
import type { FC, TeactNode } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types';
import { ApiMediaFormat } from '../../api/types';
import { IS_CANVAS_FILTER_SUPPORTED, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { import {
getChatAvatarHash, getChatAvatarHash,
getChatTitle, getChatTitle,
@ -18,6 +18,9 @@ import buildClassName from '../../util/buildClassName';
import { getFirstLetters } from '../../util/textFormat'; import { getFirstLetters } from '../../util/textFormat';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useFlag from '../../hooks/useFlag';
import useMediaTransition from '../../hooks/useMediaTransition';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import OptimizedVideo from '../ui/OptimizedVideo'; import OptimizedVideo from '../ui/OptimizedVideo';
@ -27,7 +30,6 @@ import './ProfilePhoto.scss';
type OwnProps = { type OwnProps = {
chat?: ApiChat; chat?: ApiChat;
user?: ApiUser; user?: ApiUser;
isFirstPhoto?: boolean;
isSavedMessages?: boolean; isSavedMessages?: boolean;
photo?: ApiPhoto; photo?: ApiPhoto;
lastSyncTime?: number; lastSyncTime?: number;
@ -39,7 +41,6 @@ const ProfilePhoto: FC<OwnProps> = ({
chat, chat,
user, user,
photo, photo,
isFirstPhoto,
isSavedMessages, isSavedMessages,
canPlayVideo, canPlayVideo,
lastSyncTime, lastSyncTime,
@ -49,32 +50,32 @@ const ProfilePhoto: FC<OwnProps> = ({
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const lang = useLang(); const lang = useLang();
const isDeleted = user && isDeletedUser(user); const isDeleted = user && isDeletedUser(user);
const isRepliesChat = chat && isChatWithRepliesBot(chat.id); const isRepliesChat = chat && isChatWithRepliesBot(chat.id);
const userOrChat = user || chat;
const currentPhoto = photo || userOrChat?.fullInfo?.profilePhoto;
const canHaveMedia = userOrChat && !isSavedMessages && !isDeleted && !isRepliesChat;
const { isVideo } = currentPhoto || {};
function getMediaHash(size: 'normal' | 'big', type: 'photo' | 'video' = 'photo') { const avatarHash = canHaveMedia && getChatAvatarHash(userOrChat, 'normal', 'photo');
const userOrChat = user || chat; const avatarBlobUrl = useMedia(avatarHash, undefined, undefined, lastSyncTime);
const profilePhoto = photo || userOrChat?.fullInfo?.profilePhoto;
const hasVideo = profilePhoto?.isVideo;
const forceAvatar = isFirstPhoto;
if (type === 'video' && !hasVideo) return undefined; const photoHash = canHaveMedia && currentPhoto && !isVideo && `photo${currentPhoto.id}?size=c`;
const photoBlobUrl = useMedia(photoHash, undefined, undefined, lastSyncTime);
if (photo && !forceAvatar) { const videoHash = canHaveMedia && currentPhoto && isVideo && getVideoAvatarMediaHash(currentPhoto);
if (hasVideo && type === 'video') { const videoBlobUrl = useMedia(videoHash, undefined, undefined, lastSyncTime);
return getVideoAvatarMediaHash(photo);
}
if (type === 'photo') {
return `photo${photo.id}?size=c`;
}
}
if (!isSavedMessages && !isDeleted && !isRepliesChat && userOrChat) { const fullMediaData = videoBlobUrl || photoBlobUrl;
return getChatAvatarHash(userOrChat, size, type); const [isVideoReady, markVideoReady] = useFlag();
} const isFullMediaReady = Boolean(fullMediaData && (!isVideo || isVideoReady));
const transitionClassNames = useMediaTransition(isFullMediaReady);
return undefined; const isBlurredThumb = canHaveMedia && !isFullMediaReady && !avatarBlobUrl && currentPhoto?.thumbnail?.dataUri;
} const blurredThumbCanvasRef = useCanvasBlur(
currentPhoto?.thumbnail?.dataUri, !isBlurredThumb, IS_SINGLE_COLUMN_LAYOUT && !IS_CANVAS_FILTER_SUPPORTED,
);
const hasMedia = currentPhoto || avatarBlobUrl || isBlurredThumb;
useEffect(() => { useEffect(() => {
if (videoRef.current && !canPlayVideo) { if (videoRef.current && !canPlayVideo) {
@ -82,12 +83,6 @@ const ProfilePhoto: FC<OwnProps> = ({
} }
}, [canPlayVideo]); }, [canPlayVideo]);
const photoHash = getMediaHash('big', 'photo');
const photoBlobUrl = useMedia(photoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
const videoHash = getMediaHash('normal', 'video');
const videoBlobUrl = useMedia(videoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime);
const imageSrc = videoBlobUrl || photoBlobUrl || photo?.thumbnail?.dataUri;
let content: TeactNode | undefined; let content: TeactNode | undefined;
if (isSavedMessages) { if (isSavedMessages) {
@ -96,23 +91,37 @@ const ProfilePhoto: FC<OwnProps> = ({
content = <i className="icon-avatar-deleted-account" />; content = <i className="icon-avatar-deleted-account" />;
} else if (isRepliesChat) { } else if (isRepliesChat) {
content = <i className="icon-reply-filled" />; content = <i className="icon-reply-filled" />;
} else if (imageSrc) { } else if (hasMedia) {
if (videoBlobUrl) { content = (
content = ( <>
<OptimizedVideo {isBlurredThumb ? (
canPlay={canPlayVideo} <canvas ref={blurredThumbCanvasRef} className="thumb" />
ref={videoRef} ) : (
src={imageSrc} <img src={avatarBlobUrl} className="thumb" alt="" />
className="avatar-media" )}
muted {currentPhoto && (
disablePictureInPicture isVideo ? (
loop <OptimizedVideo
playsInline canPlay={canPlayVideo}
/> ref={videoRef}
); src={fullMediaData}
} else { className={buildClassName('avatar-media', transitionClassNames)}
content = <img src={imageSrc} className="avatar-media" alt="" />; muted
} disablePictureInPicture
loop
playsInline
onPlay={markVideoReady}
/>
) : (
<img
src={fullMediaData}
className={buildClassName('avatar-media', transitionClassNames)}
alt=""
/>
)
)}
</>
);
} else if (user) { } else if (user) {
const userFullName = getUserFullName(user); const userFullName = getUserFullName(user);
content = userFullName ? getFirstLetters(userFullName, 2) : undefined; content = userFullName ? getFirstLetters(userFullName, 2) : undefined;
@ -133,11 +142,11 @@ const ProfilePhoto: FC<OwnProps> = ({
isSavedMessages && 'saved-messages', isSavedMessages && 'saved-messages',
isDeleted && 'deleted-account', isDeleted && 'deleted-account',
isRepliesChat && 'replies-bot-account', isRepliesChat && 'replies-bot-account',
(!isSavedMessages && !imageSrc) && 'no-photo', (!isSavedMessages && !hasMedia) && 'no-photo',
); );
return ( return (
<div className={fullClassName} onClick={imageSrc ? onClick : undefined}> <div className={fullClassName} onClick={hasMedia ? onClick : undefined}>
{typeof content === 'string' ? renderText(content, ['hq_emoji']) : content} {typeof content === 'string' ? renderText(content, ['hq_emoji']) : content}
</div> </div>
); );

View File

@ -1,8 +1,7 @@
import { useEffect, useRef } from '../lib/teact/teact'; import { useEffect, useRef } from '../lib/teact/teact';
import fastBlur from '../lib/fastBlur';
import useForceUpdate from './useForceUpdate';
import { IS_CANVAS_FILTER_SUPPORTED } from '../util/environment'; import { IS_CANVAS_FILTER_SUPPORTED } from '../util/environment';
import fastBlur from '../lib/fastBlur';
const RADIUS = 2; const RADIUS = 2;
const ITERATIONS = 2; const ITERATIONS = 2;
@ -17,15 +16,17 @@ export default function useCanvasBlur(
) { ) {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const forceUpdate = useForceUpdate(); const isStarted = useRef();
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!dataUri || !canvas || isDisabled) { if (!dataUri || !canvas || isDisabled || isStarted.current) {
return; return;
} }
isStarted.current = true;
const img = new Image(); const img = new Image();
const processBlur = () => { const processBlur = () => {
@ -54,7 +55,7 @@ export default function useCanvasBlur(
}; };
img.src = dataUri; img.src = dataUri;
}, [canvasRef, dataUri, forceUpdate, isDisabled, preferredHeight, preferredWidth, withRaf, radius]); }, [dataUri, isDisabled, preferredHeight, preferredWidth, radius, withRaf]);
return canvasRef; return canvasRef;
} }