signal-desktop/ts/components/GroupCallRemoteParticipant.tsx

294 lines
8 KiB
TypeScript
Raw Normal View History

2020-11-13 19:57:55 +00:00
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2020-11-17 19:49:48 +00:00
import React, {
useState,
useRef,
useMemo,
useCallback,
useEffect,
CSSProperties,
} from 'react';
import classNames from 'classnames';
2020-11-13 19:57:55 +00:00
import { noop } from 'lodash';
import {
GroupCallRemoteParticipantType,
VideoFrameSource,
} from '../types/Calling';
import { LocalizerType } from '../types/Util';
2020-11-13 19:57:55 +00:00
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationModal } from './ConfirmationModal';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
2020-11-13 19:57:55 +00:00
2020-11-17 19:49:48 +00:00
// The max size video frame we'll support (in RGBA)
const FRAME_BUFFER_SIZE = 1920 * 1080 * 4;
interface BasePropsType {
2020-11-13 19:57:55 +00:00
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
remoteParticipant: GroupCallRemoteParticipantType;
2020-11-17 19:49:48 +00:00
}
interface InPipPropsType {
isInPip: true;
}
interface NotInPipPropsType {
height: number;
isInPip?: false;
2020-11-13 19:57:55 +00:00
left: number;
top: number;
width: number;
2020-11-13 19:57:55 +00:00
}
export type PropsType = BasePropsType & (InPipPropsType | NotInPipPropsType);
2020-11-17 19:49:48 +00:00
export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
props => {
const { getGroupCallVideoFrameSource, i18n } = props;
const {
avatarPath,
color,
demuxId,
hasRemoteAudio,
hasRemoteVideo,
isBlocked,
profileName,
title,
} = props.remoteParticipant;
const [isWide, setIsWide] = useState(true);
const [hasHover, setHover] = useState(false);
const [showBlockInfo, setShowBlockInfo] = useState(false);
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null);
const frameBufferRef = useRef<ArrayBuffer>(
new ArrayBuffer(FRAME_BUFFER_SIZE)
);
2020-11-13 19:57:55 +00:00
const videoFrameSource = useMemo(
() => getGroupCallVideoFrameSource(demuxId),
[getGroupCallVideoFrameSource, demuxId]
2020-11-17 19:49:48 +00:00
);
const renderVideoFrame = useCallback(() => {
const canvasEl = remoteVideoRef.current;
if (!canvasEl) {
return;
}
const canvasContext = canvasContextRef.current;
if (!canvasContext) {
return;
}
const frameDimensions = videoFrameSource.receiveVideoFrame(
frameBufferRef.current
);
if (!frameDimensions) {
return;
}
2020-11-17 19:49:48 +00:00
const [frameWidth, frameHeight] = frameDimensions;
if (frameWidth < 2 || frameHeight < 2) {
return;
}
canvasEl.width = frameWidth;
canvasEl.height = frameHeight;
canvasContext.putImageData(
new ImageData(
new Uint8ClampedArray(
frameBufferRef.current,
0,
frameWidth * frameHeight * 4
),
frameWidth,
frameHeight
2020-11-17 19:49:48 +00:00
),
0,
0
);
setIsWide(frameWidth > frameHeight);
}, [videoFrameSource]);
useEffect(() => {
if (!hasRemoteVideo) {
return noop;
}
let rafId = requestAnimationFrame(tick);
function tick() {
renderVideoFrame();
rafId = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(rafId);
};
}, [hasRemoteVideo, renderVideoFrame, videoFrameSource]);
let canvasStyles: CSSProperties;
let containerStyles: CSSProperties;
2020-11-17 19:49:48 +00:00
// If our `width` and `height` props don't match the canvas's aspect ratio, we want to
// fill the container. This can happen when RingRTC gives us an inaccurate
// `videoAspectRatio`, or if the container is an unexpected size.
if (isWide) {
canvasStyles = { width: '100%' };
2020-11-13 19:57:55 +00:00
} else {
canvasStyles = { height: '100%' };
2020-11-17 19:49:48 +00:00
}
let avatarSize: number;
// TypeScript isn't smart enough to know that `isInPip` by itself disambiguates the
// types, so we have to use `props.isInPip` instead.
// eslint-disable-next-line react/destructuring-assignment
if (props.isInPip) {
containerStyles = canvasStyles;
avatarSize = AvatarSize.FIFTY_TWO;
} else {
const { top, left, width, height } = props;
const shorterDimension = Math.min(width, height);
if (shorterDimension >= 240) {
avatarSize = AvatarSize.ONE_HUNDRED_TWELVE;
} else if (shorterDimension >= 180) {
avatarSize = AvatarSize.EIGHTY;
} else {
avatarSize = AvatarSize.FIFTY_TWO;
}
containerStyles = {
height,
left,
position: 'absolute',
top,
width,
};
2020-11-13 19:57:55 +00:00
}
2020-11-17 19:49:48 +00:00
const showHover = hasHover && !props.isInPip;
const canShowVideo = hasRemoteVideo && !isBlocked;
if (showBlockInfo) {
return (
<ConfirmationModal
i18n={i18n}
onClose={() => {
setShowBlockInfo(false);
}}
title={
<div className="module-ongoing-call__group-call-remote-participant__blocked--modal-title">
<Intl
i18n={i18n}
id="calling__you-have-blocked"
components={[
<ContactName
key="name"
profileName={profileName}
title={title}
i18n={i18n}
/>,
]}
/>
</div>
}
actions={[
{
text: i18n('ok'),
action: () => {
setShowBlockInfo(false);
},
style: 'affirmative',
},
]}
>
{i18n('calling__block-info')}
</ConfirmationModal>
);
}
return (
<div
className="module-ongoing-call__group-call-remote-participant"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={containerStyles}
>
{showHover && (
<div
className={classNames(
'module-ongoing-call__group-call-remote-participant--title',
{
'module-ongoing-call__group-call-remote-participant--audio-muted': !hasRemoteAudio,
}
)}
>
<ContactName
module="module-ongoing-call__group-call-remote-participant--contact-name"
profileName={profileName}
title={title}
i18n={i18n}
/>
</div>
)}
{canShowVideo ? (
<canvas
className="module-ongoing-call__group-call-remote-participant__remote-video"
style={canvasStyles}
ref={canvasEl => {
remoteVideoRef.current = canvasEl;
if (canvasEl) {
canvasContextRef.current = canvasEl.getContext('2d', {
alpha: false,
desynchronized: true,
storage: 'discardable',
} as CanvasRenderingContext2DSettings);
} else {
canvasContextRef.current = null;
}
}}
/>
) : (
<CallBackgroundBlur avatarPath={avatarPath} color={color}>
{isBlocked ? (
<>
<i className="module-ongoing-call__group-call-remote-participant__blocked" />
<button
type="button"
className="module-ongoing-call__group-call-remote-participant__blocked--info"
onClick={() => {
setShowBlockInfo(true);
}}
>
{i18n('moreInfo')}
</button>
</>
) : (
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
profileName={profileName}
title={title}
size={avatarSize}
/>
)}
</CallBackgroundBlur>
)}
</div>
);
2020-11-13 19:57:55 +00:00
}
);