One-Time Video: Show and play one-time video (#4258)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2024-02-23 14:05:54 +01:00
parent 80e70d2899
commit cb9330bcef
10 changed files with 194 additions and 54 deletions

View File

@ -78,10 +78,19 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC
if (isExpiredVoice) {
return { isExpiredVoice };
}
const isExpiredRoundVideo = isExpiredRoundVideoMessage(media);
if (isExpiredRoundVideo) {
return { isExpiredRoundVideo };
}
const voice = buildVoice(media);
if (voice) return { voice, ttlSeconds };
if ('round' in media && media.round) {
const video = buildVideo(media);
if (video) return { video, ttlSeconds };
}
// Other disappearing media types are not supported
if (ttlSeconds !== undefined) {
return undefined;
@ -270,6 +279,13 @@ function isExpiredVoiceMessage(media: GramJs.TypeMessageMedia): MediaContent['is
return !media.document && media.voice;
}
function isExpiredRoundVideoMessage(media: GramJs.TypeMessageMedia): MediaContent['isExpiredRoundVideo'] {
if (!(media instanceof GramJs.MessageMediaDocument)) {
return false;
}
return !media.document && media.round;
}
function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined {
if (
!(media instanceof GramJs.MessageMediaDocument)

View File

@ -485,8 +485,9 @@ export type MediaContent = {
storyData?: ApiMessageStoryData;
giveaway?: ApiGiveaway;
giveawayResults?: ApiGiveawayResults;
ttlSeconds?: number;
isExpiredVoice?: boolean;
isExpiredRoundVideo?: boolean;
ttlSeconds?: number;
};
export interface ApiMessage {

View File

@ -128,7 +128,7 @@ const Audio: FC<OwnProps> = ({
const coverHash = getMessageMediaHash(message, 'pictogram');
const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl);
const hasTtl = hasMessageTtl(message);
const isOneTimeModalOrigin = origin === AudioOrigin.OneTimeModal;
const isInOneTimeModal = origin === AudioOrigin.OneTimeModal;
const trackType = isVoice ? (hasTtl ? 'oneTimeVoice' : 'voice') : 'audio';
const mediaData = useMedia(
@ -156,7 +156,7 @@ const Audio: FC<OwnProps> = ({
isBuffered, bufferedRanges, bufferingHandlers, checkBuffering,
} = useBuffering();
const noReset = isOneTimeModalOrigin;
const noReset = isInOneTimeModal;
const {
isPlaying, playProgress, playPause, setCurrentTime, duration,
} = useAudioPlayer(
@ -179,7 +179,7 @@ const Audio: FC<OwnProps> = ({
const reversePlayProgress = 1 - playProgress;
const isOwn = isOwnMessage(message);
const isReverse = hasTtl && isOneTimeModalOrigin;
const isReverse = hasTtl && isInOneTimeModal;
const waveformCanvasRef = useWaveformCanvas(
theme,
@ -279,14 +279,14 @@ const Audio: FC<OwnProps> = ({
});
useEffect(() => {
if (!seekerRef.current || !withSeekline || isOneTimeModalOrigin) return undefined;
if (!seekerRef.current || !withSeekline || isInOneTimeModal) return undefined;
return captureEvents(seekerRef.current, {
onCapture: handleStartSeek,
onRelease: handleStopSeek,
onClick: handleStopSeek,
onDrag: handleSeek,
});
}, [withSeekline, handleStartSeek, handleSeek, handleStopSeek, isOneTimeModalOrigin]);
}, [withSeekline, handleStartSeek, handleSeek, handleStopSeek, isInOneTimeModal]);
function renderFirstLine() {
if (isVoice) {
@ -323,7 +323,7 @@ const Audio: FC<OwnProps> = ({
const fullClassName = buildClassName(
'Audio',
className,
isOneTimeModalOrigin && 'non-interactive',
isInOneTimeModal && 'non-interactive',
origin === AudioOrigin.Inline && 'inline',
isOwn && origin === AudioOrigin.Inline && 'own',
(origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger',
@ -383,11 +383,11 @@ const Audio: FC<OwnProps> = ({
onClick={handleButtonClick}
isRtl={lang.isRtl}
backgroundImage={coverBlobUrl}
nonInteractive={isOneTimeModalOrigin}
nonInteractive={isInOneTimeModal}
>
{!isOneTimeModalOrigin && <Icon name="play" />}
{!isOneTimeModalOrigin && <Icon name="pause" />}
{isOneTimeModalOrigin && (
{!isInOneTimeModal && <Icon name="play" />}
{!isInOneTimeModal && <Icon name="pause" />}
{isInOneTimeModal && (
<AnimatedIcon
className="flame"
tgsUrl={LOCAL_TGS_URLS.Flame}
@ -397,7 +397,7 @@ const Audio: FC<OwnProps> = ({
/>
)}
</Button>
{hasTtl && !isOneTimeModalOrigin && (
{hasTtl && !isInOneTimeModal && (
<Icon name="view-once" />
)}
</div>
@ -424,7 +424,7 @@ const Audio: FC<OwnProps> = ({
/>
</div>
)}
{isOneTimeModalOrigin && !shouldRenderSpinner && (
{isInOneTimeModal && !shouldRenderSpinner && (
<div className={buildClassName('media-loading')}>
<ProgressSpinner
progress={playProgress}
@ -461,7 +461,7 @@ const Audio: FC<OwnProps> = ({
onDateClick ? handleDateClick : undefined,
)}
{origin === AudioOrigin.SharedMedia && (voice || video) && renderWithTitle()}
{(origin === AudioOrigin.Inline || isOneTimeModalOrigin) && voice && (
{(origin === AudioOrigin.Inline || isInOneTimeModal) && voice && (
renderVoice(
voice,
seekerRef,

View File

@ -964,6 +964,7 @@ const Message: FC<OwnProps & StateProps> = ({
metaPosition === 'in-text' && 'with-meta',
outgoingStatus && 'with-outgoing-icon',
);
const shouldReadMedia = !hasTtl || !isOwn || isChatWithSelf;
return (
<div className={className} onDoubleClick={handleContentDoubleClick} dir="auto">
@ -1086,6 +1087,7 @@ const Message: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersectionForLoading}
canAutoLoad={canAutoLoadMedia}
isDownloading={isDownloading}
onReadMedia={shouldReadMedia ? handleReadMedia : undefined}
/>
)}
{!isAlbum && video && !video.isRound && (
@ -1115,7 +1117,7 @@ const Message: FC<OwnProps & StateProps> = ({
isSelected={isSelected}
noAvatars={noAvatars}
onPlay={handleAudioPlay}
onReadMedia={voice && (!isOwn || isChatWithSelf || (isOwn && !hasTtl)) ? handleReadMedia : undefined}
onReadMedia={voice && shouldReadMedia ? handleReadMedia : undefined}
onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
isTranscribing={isTranscribing}

View File

@ -4,15 +4,24 @@
height: 15rem;
cursor: var(--custom-cursor, pointer);
&.non-interactive {
pointer-events: none;
}
.video-wrapper {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
.thumbnail {
border-radius: 50%;
}
@ -38,4 +47,24 @@
video::-webkit-media-controls-start-playback-button {
display: none;
}
.play-wrapper {
position: absolute;
.icon-play {
margin: 0 auto;
}
.icon-view-once {
position: absolute;
background-color: rgba(0, 0, 0, 0.75);
padding: 0.125rem;
left: 1.625rem;
bottom: 0;
font-size: 1rem;
border-radius: 50%;
color: var(--color-white);
z-index: var(--z-badge);
}
}
}

View File

@ -11,7 +11,9 @@ import type { ApiMessage } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../../api/types';
import { getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri } from '../../../global/helpers';
import {
getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri, hasMessageTtl,
} from '../../../global/helpers';
import { stopCurrentAudio } from '../../../util/audioPlayer';
import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dateFormat';
@ -29,6 +31,9 @@ import useShowTransition from '../../../hooks/useShowTransition';
import useSignal from '../../../hooks/useSignal';
import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import Icon from '../../common/Icon';
import MediaSpoiler from '../../common/MediaSpoiler';
import Button from '../../ui/Button';
import OptimizedVideo from '../../ui/OptimizedVideo';
import ProgressSpinner from '../../ui/ProgressSpinner';
@ -36,9 +41,13 @@ import './RoundVideo.scss';
type OwnProps = {
message: ApiMessage;
observeIntersection: ObserveFn;
className?: string;
canAutoLoad?: boolean;
isDownloading?: boolean;
origin?: 'oneTimeModal';
observeIntersection?: ObserveFn;
onStop?: NoneToVoidFunction;
onReadMedia?: NoneToVoidFunction;
};
const PROGRESS_CENTER = ROUND_VIDEO_DIMENSIONS_PX / 2;
@ -50,9 +59,13 @@ let stopPrevious: NoneToVoidFunction;
const RoundVideo: FC<OwnProps> = ({
message,
observeIntersection,
className,
canAutoLoad,
isDownloading,
origin,
observeIntersection,
onStop,
onReadMedia,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
@ -63,6 +76,8 @@ const RoundVideo: FC<OwnProps> = ({
const video = message.content.video!;
const { cancelMessageMediaDownload, openOneTimeMediaModal } = getActions();
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
@ -80,11 +95,14 @@ const RoundVideo: FC<OwnProps> = ({
);
const [isPlayerReady, markPlayerReady] = useFlag();
const hasTtl = hasMessageTtl(message);
const isInOneTimeModal = origin === 'oneTimeModal';
const shouldRenderSpoiler = hasTtl && !isInOneTimeModal;
const hasThumb = Boolean(getMessageMediaThumbDataUri(message));
const noThumb = !hasThumb || isPlayerReady;
const noThumb = !hasThumb || isPlayerReady || shouldRenderSpoiler;
const thumbRef = useBlurredMediaThumbRef(message, noThumb);
const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message);
const isTransferring = (isLoadAllowed && !isPlayerReady) || isDownloading;
const wasLoadDisabled = usePrevious(isLoadAllowed) === false;
@ -133,18 +151,7 @@ const RoundVideo: FC<OwnProps> = ({
stopPrevious = stopPlaying;
});
const handleClick = useLastCallback(() => {
if (!mediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
return;
}
if (isDownloading) {
getActions().cancelMessageMediaDownload({ message });
return;
}
const tooglePlaying = useLastCallback(() => {
const playerEl = playerRef.current!;
if (isActivated) {
if (playerEl.paused) {
@ -160,25 +167,77 @@ const RoundVideo: FC<OwnProps> = ({
playerEl.currentTime = 0;
safePlay(playerEl);
stopCurrentAudio();
setIsActivated(true);
}
});
useEffect(() => {
if (!isInOneTimeModal) {
return;
}
tooglePlaying();
}, [isInOneTimeModal]);
const handleClick = useLastCallback(() => {
if (!mediaData) {
setIsLoadAllowed((isAllowed) => !isAllowed);
return;
}
if (isDownloading) {
cancelMessageMediaDownload({ message });
return;
}
if (hasTtl && !isInOneTimeModal) {
openOneTimeMediaModal({ message });
onReadMedia?.();
return;
}
tooglePlaying();
});
const handleTimeUpdate = useLastCallback((e: React.UIEvent<HTMLVideoElement>) => {
const playerEl = e.currentTarget;
setProgress(playerEl.currentTime / playerEl.duration);
});
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>
);
}
return (
<div
ref={ref}
className="RoundVideo media-inner"
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}
@ -186,22 +245,24 @@ const RoundVideo: FC<OwnProps> = ({
className="full-media"
width={ROUND_VIDEO_DIMENSIONS_PX}
height={ROUND_VIDEO_DIMENSIONS_PX}
autoPlay
autoPlay={!shouldRenderSpoiler}
disablePictureInPicture
muted={!isActivated}
loop={!isActivated}
playsInline
onEnded={isActivated ? stopPlaying : undefined}
onEnded={isActivated ? onStop ?? stopPlaying : undefined}
onTimeUpdate={isActivated ? handleTimeUpdate : undefined}
onReady={markPlayerReady}
/>
</div>
)}
<canvas
ref={thumbRef}
className={buildClassName('thumbnail', thumbClassNames)}
style={`width: ${ROUND_VIDEO_DIMENSIONS_PX}px; height: ${ROUND_VIDEO_DIMENSIONS_PX}px`}
/>
{!shouldRenderSpoiler && (
<canvas
ref={thumbRef}
className={buildClassName('thumbnail', thumbClassNames)}
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}>
@ -223,13 +284,16 @@ const RoundVideo: FC<OwnProps> = ({
<ProgressSpinner progress={isDownloading ? downloadProgress : loadProgress} />
</div>
)}
{shouldRenderSpoiler && !shouldSpinnerRender && renderPlayWrapper()}
{!mediaData && !isLoadAllowed && (
<i className="icon icon-download" />
)}
<div className="message-media-duration">
{isActivated ? formatMediaDuration(playerRef.current!.currentTime) : formatMediaDuration(video.duration)}
{(!isActivated || playerRef.current!.paused) && <i className="icon icon-muted" />}
</div>
{!isInOneTimeModal && (
<div className="message-media-duration">
{isActivated ? formatMediaDuration(playerRef.current!.currentTime) : formatMediaDuration(video.duration)}
{(!isActivated || playerRef.current!.paused) && <Icon name="muted" />}
</div>
)}
</div>
);
};

View File

@ -10,7 +10,7 @@
flex-direction: column;
backdrop-filter: blur(2rem);
animation: fade-in-opacity 0.3s ease;
background-color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--z-modal-confirm);
align-items: center;
transition: opacity 0.3s ease;
@ -20,10 +20,14 @@
}
}
.main {
background-color: var(--color-background);
.voice {
padding: 0.6875rem;
border-radius: 1rem;
background-color: var(--color-background);
}
.video {
background: transparent;
}
.footer {

View File

@ -14,6 +14,7 @@ import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition';
import Audio from '../../common/Audio';
import RoundVideo from '../../middle/message/RoundVideo';
import Button from '../../ui/Button';
import styles from './OneTimeMediaModal.module.scss';
@ -54,9 +55,14 @@ const OneTimeMediaModal = ({
const closeBtnTitle = isOwn ? lang('Chat.Voice.Single.Close') : lang('Chat.Voice.Single.DeleteAndClose');
function renderMedia() {
if (message?.content?.voice) {
if (!message?.content) {
return undefined;
}
const { voice, video } = message.content;
if (voice) {
return (
<Audio
className={styles.voice}
theme={theme}
message={message}
origin={AudioOrigin.OneTimeModal}
@ -65,13 +71,22 @@ const OneTimeMediaModal = ({
onPause={handleClose}
/>
);
} else if (video?.isRound) {
return (
<RoundVideo
className={styles.video}
message={message}
origin="oneTimeModal"
onStop={handleClose}
/>
);
}
return undefined;
}
return (
<div className={buildClassName(styles.root, transitionClassNames)}>
<div className={styles.main}>{renderMedia()}</div>
{renderMedia()}
<div className={styles.footer}>
<Button
faded

View File

@ -324,15 +324,18 @@ export function extractMessageText(message: ApiMessage | ApiStory, inChatList =
}
export function getExpiredMessageDescription(langFn: LangFn, message: ApiMessage): string | undefined {
const { isExpiredVoice } = message.content;
const { isExpiredVoice, isExpiredRoundVideo } = message.content;
if (isExpiredVoice) {
return langFn('Message.VoiceMessageExpired');
} else if (isExpiredRoundVideo) {
return langFn('Message.VideoMessageExpired');
}
return undefined;
}
export function isExpiredMessage(message: ApiMessage) {
return Boolean(message.content?.isExpiredVoice);
const { isExpiredVoice, isExpiredRoundVideo } = message.content ?? {};
return Boolean(isExpiredVoice || isExpiredRoundVideo);
}
export function hasMessageTtl(message: ApiMessage) {

View File

@ -206,6 +206,12 @@ export function updateChatMessage<T extends GlobalState>(
voice: undefined,
isExpiredVoice: true,
};
} else if (message.content.video?.isRound) {
messageUpdate.content = {
...messageUpdate.content,
video: undefined,
isExpiredRoundVideo: true,
};
}
}
const updatedMessage = {