373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import type { FC } from '../../../lib/teact/teact';
|
||
import type React from '../../../lib/teact/teact';
|
||
import {
|
||
useEffect, useLayoutEffect,
|
||
useRef, useSignal, useState,
|
||
} from '../../../lib/teact/teact';
|
||
import { getActions } from '../../../global';
|
||
|
||
import type { ApiMessage } from '../../../api/types';
|
||
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
|
||
import { ApiMediaFormat } from '../../../api/types';
|
||
|
||
import {
|
||
getMediaFormat,
|
||
getVideoMediaHash,
|
||
hasMessageTtl,
|
||
} from '../../../global/helpers';
|
||
import { stopCurrentAudio } from '../../../util/audioPlayer';
|
||
import buildClassName from '../../../util/buildClassName';
|
||
import { formatMediaDuration } from '../../../util/dates/dateFormat';
|
||
import safePlay from '../../../util/safePlay';
|
||
import { ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions';
|
||
|
||
import useThumbnail from '../../../hooks/media/useThumbnail';
|
||
import { useThrottledSignal } from '../../../hooks/useAsyncResolvers';
|
||
import useFlag from '../../../hooks/useFlag';
|
||
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
|
||
import useLastCallback from '../../../hooks/useLastCallback';
|
||
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 Button from '../../ui/Button';
|
||
import OptimizedVideo from '../../ui/OptimizedVideo';
|
||
import ProgressSpinner from '../../ui/ProgressSpinner';
|
||
|
||
import './RoundVideo.scss';
|
||
|
||
type OwnProps = {
|
||
message: ApiMessage;
|
||
className?: string;
|
||
canAutoLoad?: boolean;
|
||
isDownloading?: boolean;
|
||
origin?: 'oneTimeModal';
|
||
observeIntersection?: ObserveFn;
|
||
onStop?: NoneToVoidFunction;
|
||
onReadMedia?: NoneToVoidFunction;
|
||
onHideTranscription?: (isHidden: boolean) => void;
|
||
isTranscriptionError?: boolean;
|
||
canTranscribe?: boolean;
|
||
isTranscribed?: boolean;
|
||
isTranscriptionHidden?: boolean;
|
||
isTranscribing?: boolean;
|
||
};
|
||
|
||
const PROGRESS_CENTER = ROUND_VIDEO_DIMENSIONS_PX / 2;
|
||
const PROGRESS_MARGIN = 6;
|
||
const PROGRESS_CIRCUMFERENCE = (PROGRESS_CENTER - PROGRESS_MARGIN) * 2 * Math.PI;
|
||
const PROGRESS_THROTTLE = 16; // Min period needed for `playerEl.currentTime` to update
|
||
|
||
let stopPrevious: NoneToVoidFunction;
|
||
|
||
const RoundVideo: FC<OwnProps> = ({
|
||
message,
|
||
className,
|
||
canAutoLoad,
|
||
isDownloading,
|
||
origin,
|
||
observeIntersection,
|
||
onStop,
|
||
onReadMedia,
|
||
isTranscriptionError,
|
||
isTranscribed,
|
||
canTranscribe,
|
||
onHideTranscription,
|
||
isTranscriptionHidden,
|
||
isTranscribing,
|
||
}) => {
|
||
const ref = useRef<HTMLDivElement>();
|
||
const playerRef = useRef<HTMLVideoElement>();
|
||
const circleRef = useRef<SVGCircleElement>();
|
||
|
||
const { cancelMediaDownload, openOneTimeMediaModal, transcribeAudio } = getActions();
|
||
|
||
const isIntersecting = useIsIntersecting(ref, observeIntersection);
|
||
|
||
const video = message.content.video!;
|
||
const isMediaUnread = message.isMediaUnread;
|
||
|
||
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
|
||
const shouldLoad = Boolean(isLoadAllowed && isIntersecting);
|
||
const { mediaData, loadProgress } = useMediaWithLoadProgress(
|
||
getVideoMediaHash(video, 'inline'),
|
||
!shouldLoad,
|
||
getMediaFormat(video, 'inline'),
|
||
);
|
||
|
||
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
|
||
getVideoMediaHash(video, 'download'),
|
||
!isDownloading,
|
||
ApiMediaFormat.BlobUrl,
|
||
);
|
||
|
||
const [isPlayerReady, markPlayerReady] = useFlag();
|
||
const hasTtl = hasMessageTtl(message);
|
||
const isInOneTimeModal = origin === 'oneTimeModal';
|
||
const shouldRenderSpoiler = hasTtl && !isInOneTimeModal;
|
||
const thumbDataUri = useThumbnail(message);
|
||
const hasThumb = Boolean(thumbDataUri);
|
||
const noThumb = !hasThumb || isPlayerReady || shouldRenderSpoiler;
|
||
const thumbRef = useBlurredMediaThumbRef(video, noThumb);
|
||
useMediaTransition({ hasMediaData: !noThumb, ref: thumbRef });
|
||
|
||
const isTransferring = (isLoadAllowed && !isPlayerReady) || isDownloading;
|
||
const wasLoadDisabled = usePreviousDeprecated(isLoadAllowed) === false;
|
||
|
||
const {
|
||
ref: spinnerRef,
|
||
shouldRender: shouldRenderSpinner,
|
||
} = useShowTransition({
|
||
isOpen: isTransferring,
|
||
noMountTransition: wasLoadDisabled,
|
||
withShouldRender: true,
|
||
});
|
||
|
||
const [isActivated, setIsActivated] = useState(false);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
|
||
const [getProgress, setProgress] = useSignal(0);
|
||
const getThrottledProgress = useThrottledSignal(getProgress, PROGRESS_THROTTLE);
|
||
|
||
useEffect(() => {
|
||
if (!isActivated) {
|
||
return;
|
||
}
|
||
|
||
const playerEl = playerRef.current!;
|
||
setProgress(playerEl.currentTime / playerEl.duration);
|
||
}, [setProgress, isActivated, getThrottledProgress]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!isActivated || !circleRef.current) {
|
||
return;
|
||
}
|
||
|
||
const strokeDashOffset = PROGRESS_CIRCUMFERENCE - getThrottledProgress() * PROGRESS_CIRCUMFERENCE;
|
||
circleRef.current.setAttribute('stroke-dashoffset', strokeDashOffset.toString());
|
||
}, [isActivated, getThrottledProgress]);
|
||
|
||
const shouldPlay = Boolean(mediaData && isIntersecting);
|
||
|
||
const stopPlaying = useLastCallback(() => {
|
||
if (!playerRef.current) {
|
||
return;
|
||
}
|
||
|
||
setIsActivated(false);
|
||
setProgress(0);
|
||
safePlay(playerRef.current);
|
||
});
|
||
|
||
const capturePlaying = useLastCallback(() => {
|
||
stopPrevious?.();
|
||
stopPrevious = stopPlaying;
|
||
});
|
||
|
||
const togglePlaying = useLastCallback(() => {
|
||
const playerEl = playerRef.current!;
|
||
if (isActivated) {
|
||
if (playerEl.paused) {
|
||
safePlay(playerEl);
|
||
stopCurrentAudio();
|
||
} else {
|
||
playerEl.pause();
|
||
}
|
||
} else {
|
||
capturePlaying();
|
||
// Pause is a workaround for iOS Safari – otherwise it stops video after several frames
|
||
playerEl.pause();
|
||
playerEl.currentTime = 0;
|
||
safePlay(playerEl);
|
||
stopCurrentAudio();
|
||
setIsActivated(true);
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!isInOneTimeModal) {
|
||
return;
|
||
}
|
||
togglePlaying();
|
||
}, [isInOneTimeModal]);
|
||
|
||
const handleClick = useLastCallback((event) => {
|
||
if (event.target.closest('.transcribe-button')) {
|
||
return;
|
||
}
|
||
|
||
if (!mediaData) {
|
||
setIsLoadAllowed((isAllowed) => !isAllowed);
|
||
|
||
return;
|
||
}
|
||
|
||
if (isDownloading) {
|
||
cancelMediaDownload({ media: video });
|
||
return;
|
||
}
|
||
|
||
if (hasTtl && !isInOneTimeModal) {
|
||
openOneTimeMediaModal({ message });
|
||
onReadMedia?.();
|
||
return;
|
||
}
|
||
|
||
togglePlaying();
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (onReadMedia && isMediaUnread && isActivated) {
|
||
onReadMedia();
|
||
}
|
||
}, [isActivated, isMediaUnread, onReadMedia]);
|
||
|
||
const handleTimeUpdate = useLastCallback((e: React.UIEvent<HTMLVideoElement>) => {
|
||
const playerEl = e.currentTarget;
|
||
setProgress(playerEl.currentTime / playerEl.duration);
|
||
setCurrentTime(Math.floor(playerEl.currentTime));
|
||
});
|
||
|
||
const handleTranscribe = useLastCallback(() => {
|
||
transcribeAudio({ chatId: message.chatId, messageId: message.id });
|
||
});
|
||
|
||
function renderPlayWrapper() {
|
||
return (
|
||
<div className="play-wrapper">
|
||
<Button
|
||
color="dark"
|
||
round
|
||
size="smaller"
|
||
className="play"
|
||
nonInteractive
|
||
>
|
||
<Icon name="play" />
|
||
</Button>
|
||
<Icon name="view-once" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleButtonClick = useLastCallback(() => {
|
||
if ((isTranscribed || isTranscriptionError) && onHideTranscription) {
|
||
onHideTranscription(!isTranscriptionHidden);
|
||
} else if (!isTranscribing) {
|
||
handleTranscribe();
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className={buildClassName('RoundVideo', 'media-inner', isInOneTimeModal && 'non-interactive', className)}
|
||
onClick={handleClick}
|
||
>
|
||
{mediaData && (
|
||
<div className="video-wrapper">
|
||
{shouldRenderSpoiler && (
|
||
<MediaSpoiler
|
||
isVisible
|
||
thumbDataUri={thumbDataUri}
|
||
width={ROUND_VIDEO_DIMENSIONS_PX}
|
||
height={ROUND_VIDEO_DIMENSIONS_PX}
|
||
className="media-spoiler"
|
||
/>
|
||
)}
|
||
<OptimizedVideo
|
||
canPlay={shouldPlay}
|
||
ref={playerRef}
|
||
src={mediaData}
|
||
className="full-media"
|
||
width={ROUND_VIDEO_DIMENSIONS_PX}
|
||
height={ROUND_VIDEO_DIMENSIONS_PX}
|
||
autoPlay={!shouldRenderSpoiler}
|
||
disablePictureInPicture
|
||
muted={!isActivated}
|
||
defaultMuted
|
||
loop={!isActivated}
|
||
playsInline
|
||
isPriority
|
||
onEnded={isActivated ? onStop ?? stopPlaying : undefined}
|
||
onTimeUpdate={isActivated ? handleTimeUpdate : undefined}
|
||
onReady={markPlayerReady}
|
||
/>
|
||
</div>
|
||
)}
|
||
{!shouldRenderSpoiler && (
|
||
<canvas
|
||
ref={thumbRef}
|
||
className="thumbnail"
|
||
style={`width: ${ROUND_VIDEO_DIMENSIONS_PX}px; height: ${ROUND_VIDEO_DIMENSIONS_PX}px`}
|
||
/>
|
||
)}
|
||
<div className="progress">
|
||
{isActivated && (
|
||
<svg width={ROUND_VIDEO_DIMENSIONS_PX} height={ROUND_VIDEO_DIMENSIONS_PX}>
|
||
<circle
|
||
ref={circleRef}
|
||
cx={PROGRESS_CENTER}
|
||
cy={PROGRESS_CENTER}
|
||
r={PROGRESS_CENTER - PROGRESS_MARGIN}
|
||
className="progress-circle"
|
||
transform={`rotate(-90, ${PROGRESS_CENTER}, ${PROGRESS_CENTER})`}
|
||
stroke-dasharray={PROGRESS_CIRCUMFERENCE}
|
||
stroke-dashoffset={PROGRESS_CIRCUMFERENCE}
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
{shouldRenderSpinner && (
|
||
<div ref={spinnerRef} className="media-loading">
|
||
<ProgressSpinner progress={isDownloading ? downloadProgress : loadProgress} />
|
||
</div>
|
||
)}
|
||
{shouldRenderSpoiler && !shouldRenderSpinner && renderPlayWrapper()}
|
||
{!mediaData && !isLoadAllowed && (
|
||
<Icon name="download" />
|
||
)}
|
||
{!isInOneTimeModal && (
|
||
<div
|
||
className={buildClassName(
|
||
'message-media-duration', isMediaUnread && 'unread',
|
||
)}
|
||
>
|
||
{isActivated ? formatMediaDuration(currentTime) : formatMediaDuration(video.duration)}
|
||
{(!isActivated || playerRef.current!.paused) && <Icon name="muted" />}
|
||
</div>
|
||
)}
|
||
{canTranscribe && (
|
||
<Button
|
||
onClick={handleButtonClick}
|
||
className="transcribe-button"
|
||
>
|
||
{isTranscribed || isTranscriptionError ? <Icon name="down" /> : <Icon name="transcribe" />}
|
||
{isTranscribing && (
|
||
<svg viewBox="0 0 32 24" className="loading-svg">
|
||
<rect
|
||
className="loading-rect"
|
||
fill="transparent"
|
||
width="32"
|
||
height="24"
|
||
stroke-width="3"
|
||
stroke-linejoin="round"
|
||
rx="6"
|
||
ry="6"
|
||
stroke="white"
|
||
stroke-dashoffset="1"
|
||
stroke-dasharray="32,68"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RoundVideo;
|