signal-desktop/ts/components/CallScreen.tsx

479 lines
14 KiB
TypeScript
Raw Normal View History

2021-01-08 22:57:54 +00:00
// Copyright 2020-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { noop } from 'lodash';
2020-06-04 18:16:19 +00:00
import classNames from 'classnames';
import {
HangUpType,
SetLocalAudioType,
2020-08-27 00:03:42 +00:00
SetLocalPreviewType,
2020-06-04 18:16:19 +00:00
SetLocalVideoType,
2020-08-27 00:03:42 +00:00
SetRendererCanvasType,
2020-06-04 18:16:19 +00:00
} from '../state/ducks/calling';
import { Avatar } from './Avatar';
2020-11-17 15:07:53 +00:00
import { CallingHeader } from './CallingHeader';
2020-10-08 01:25:33 +00:00
import { CallingButton, CallingButtonType } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur';
2020-11-13 19:57:55 +00:00
import {
2020-12-02 18:14:03 +00:00
ActiveCallType,
2020-11-13 19:57:55 +00:00
CallMode,
CallState,
GroupCallConnectionState,
GroupCallVideoRequest,
PresentedSource,
2020-11-13 19:57:55 +00:00
VideoFrameSource,
} from '../types/Calling';
2021-08-06 00:17:05 +00:00
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { CallingToastManager } from './CallingToastManager';
2020-11-13 19:57:55 +00:00
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
import { LocalizerType } from '../types/Util';
2021-05-28 16:15:17 +00:00
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
import { missingCaseError } from '../util/missingCaseError';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
2020-06-04 18:16:19 +00:00
export type PropsType = {
activeCall: ActiveCallType;
2020-11-13 19:57:55 +00:00
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void;
2020-06-04 18:16:19 +00:00
hangUp: (_: HangUpType) => void;
i18n: LocalizerType;
joinedAt?: number;
me: {
avatarPath?: string;
2021-05-28 16:15:17 +00:00
color?: AvatarColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
openSystemPreferencesAction: () => unknown;
setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void;
2020-06-04 18:16:19 +00:00
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
2020-08-27 00:03:42 +00:00
setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
2020-08-27 00:03:42 +00:00
setRendererCanvas: (_: SetRendererCanvasType) => void;
2020-11-17 15:07:53 +00:00
stickyControls: boolean;
toggleParticipants: () => void;
2020-10-01 00:43:05 +00:00
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
2020-08-27 00:03:42 +00:00
toggleSettings: () => void;
2021-01-08 22:57:54 +00:00
toggleSpeakerView: () => void;
2020-06-04 18:16:19 +00:00
};
export const CallScreen: React.FC<PropsType> = ({
activeCall,
2020-11-13 19:57:55 +00:00
getGroupCallVideoFrameSource,
getPresentingSources,
hangUp,
i18n,
joinedAt,
me,
openSystemPreferencesAction,
setGroupCallVideoRequest,
setLocalAudio,
setLocalVideo,
setLocalPreview,
setPresenting,
setRendererCanvas,
2020-11-17 15:07:53 +00:00
stickyControls,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
2021-01-08 22:57:54 +00:00
toggleSpeakerView,
}) => {
2020-12-02 18:14:03 +00:00
const {
conversation,
hasLocalAudio,
hasLocalVideo,
isInSpeakerView,
presentingSource,
remoteParticipants,
showNeedsScreenRecordingPermissionsWarning,
2020-12-02 18:14:03 +00:00
showParticipantsList,
} = activeCall;
useActivateSpeakerViewOnPresenting(
remoteParticipants,
isInSpeakerView,
toggleSpeakerView
);
const toggleAudio = useCallback(() => {
setLocalAudio({
enabled: !hasLocalAudio,
});
}, [setLocalAudio, hasLocalAudio]);
2020-06-04 18:16:19 +00:00
const toggleVideo = useCallback(() => {
setLocalVideo({
enabled: !hasLocalVideo,
});
}, [setLocalVideo, hasLocalVideo]);
2020-06-04 18:16:19 +00:00
const togglePresenting = useCallback(() => {
if (presentingSource) {
setPresenting();
} else {
getPresentingSources();
}
}, [getPresentingSources, presentingSource, setPresenting]);
const [acceptedDuration, setAcceptedDuration] = useState<number | null>(null);
const [showControls, setShowControls] = useState(true);
2020-06-04 18:16:19 +00:00
const localVideoRef = useRef<HTMLVideoElement | null>(null);
2020-06-04 18:16:19 +00:00
useEffect(() => {
setLocalPreview({ element: localVideoRef });
return () => {
setLocalPreview({ element: undefined });
};
}, [setLocalPreview, setRendererCanvas]);
2020-06-04 18:16:19 +00:00
useEffect(() => {
if (!joinedAt) {
return noop;
2020-06-04 18:16:19 +00:00
}
// It's really jumpy with a value of 500ms.
const interval = setInterval(() => {
setAcceptedDuration(Date.now() - joinedAt);
}, 100);
return clearInterval.bind(null, interval);
}, [joinedAt]);
useEffect(() => {
2020-11-17 15:07:53 +00:00
if (!showControls || stickyControls) {
return noop;
2020-06-04 18:16:19 +00:00
}
const timer = setTimeout(() => {
setShowControls(false);
2020-06-04 18:16:19 +00:00
}, 5000);
return clearInterval.bind(null, timer);
2020-11-17 15:07:53 +00:00
}, [showControls, stickyControls]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
let eventHandled = false;
if (event.shiftKey && (event.key === 'V' || event.key === 'v')) {
toggleVideo();
eventHandled = true;
} else if (event.shiftKey && (event.key === 'M' || event.key === 'm')) {
toggleAudio();
eventHandled = true;
}
if (eventHandled) {
event.preventDefault();
event.stopPropagation();
setShowControls(true);
}
};
2020-06-04 18:16:19 +00:00
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [toggleAudio, toggleVideo]);
2020-06-04 18:16:19 +00:00
const currentPresenter = remoteParticipants.find(
participant => participant.presenting
);
const hasRemoteVideo = remoteParticipants.some(
2020-12-02 18:14:03 +00:00
remoteParticipant => remoteParticipant.hasRemoteVideo
);
2020-11-23 21:37:39 +00:00
let headerMessage: string | undefined;
let headerTitle: string | undefined;
2020-11-13 19:57:55 +00:00
let isConnected: boolean;
2020-11-23 21:37:39 +00:00
let participantCount: number;
2020-11-17 15:07:53 +00:00
let remoteParticipantsElement: JSX.Element;
2020-06-04 18:16:19 +00:00
2020-12-02 18:14:03 +00:00
switch (activeCall.callMode) {
2020-11-13 19:57:55 +00:00
case CallMode.Direct:
2020-11-23 21:37:39 +00:00
headerMessage = renderHeaderMessage(
i18n,
2020-12-02 18:14:03 +00:00
activeCall.callState || CallState.Prering,
2020-11-23 21:37:39 +00:00
acceptedDuration
);
headerTitle = conversation.title;
2020-12-02 18:14:03 +00:00
isConnected = activeCall.callState === CallState.Accepted;
2020-11-23 21:37:39 +00:00
participantCount = isConnected ? 2 : 0;
2020-11-17 15:07:53 +00:00
remoteParticipantsElement = (
2020-11-13 19:57:55 +00:00
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
/>
);
break;
case CallMode.Group:
2020-12-02 18:14:03 +00:00
participantCount = activeCall.remoteParticipants.length + 1;
2020-11-23 21:37:39 +00:00
headerMessage = undefined;
if (currentPresenter) {
headerTitle = i18n('calling__presenting--person-ongoing', [
currentPresenter.title,
]);
} else if (!activeCall.remoteParticipants.length) {
headerTitle = i18n('calling__in-this-call--zero');
}
2020-12-02 18:14:03 +00:00
isConnected =
activeCall.connectionState === GroupCallConnectionState.Connected;
2020-11-17 15:07:53 +00:00
remoteParticipantsElement = (
2020-11-13 19:57:55 +00:00
<GroupCallRemoteParticipants
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isInSpeakerView={isInSpeakerView}
2020-12-02 18:14:03 +00:00
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
2020-11-13 19:57:55 +00:00
/>
);
break;
default:
2020-12-02 18:14:03 +00:00
throw missingCaseError(activeCall);
2020-11-13 19:57:55 +00:00
}
const isLonelyInGroup =
activeCall.callMode === CallMode.Group &&
!activeCall.remoteParticipants.length;
let videoButtonType: CallingButtonType;
if (presentingSource) {
videoButtonType = CallingButtonType.VIDEO_DISABLED;
} else if (hasLocalVideo) {
videoButtonType = CallingButtonType.VIDEO_ON;
} else {
videoButtonType = CallingButtonType.VIDEO_OFF;
}
const audioButtonType = hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
2020-11-13 19:57:55 +00:00
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && !isConnected,
'module-ongoing-call__controls--fadeOut':
!showControls && !isAudioOnly && isConnected,
});
const isGroupCall = activeCall.callMode === CallMode.Group;
const localPreviewVideoClass = classNames({
'module-ongoing-call__footer__local-preview__video': true,
'module-ongoing-call__footer__local-preview__video--presenting': Boolean(
presentingSource
),
});
let presentingButtonType: CallingButtonType;
if (presentingSource) {
presentingButtonType = CallingButtonType.PRESENTING_ON;
} else if (currentPresenter) {
presentingButtonType = CallingButtonType.PRESENTING_DISABLED;
} else {
presentingButtonType = CallingButtonType.PRESENTING_OFF;
}
2021-06-02 17:42:41 +00:00
const isSendingVideo = hasLocalVideo || presentingSource;
return (
<div
2020-11-13 19:57:55 +00:00
className={classNames(
'module-calling__container',
`module-ongoing-call__container--${getCallModeClassSuffix(
2020-12-02 18:14:03 +00:00
activeCall.callMode
2020-11-13 19:57:55 +00:00
)}`
)}
onMouseMove={() => {
setShowControls(true);
}}
role="group"
>
{showNeedsScreenRecordingPermissionsWarning ? (
<NeedsScreenRecordingPermissionsModal
toggleScreenRecordingPermissionsDialog={
toggleScreenRecordingPermissionsDialog
}
i18n={i18n}
openSystemPreferencesAction={openSystemPreferencesAction}
/>
) : null}
<CallingToastManager activeCall={activeCall} i18n={i18n} />
2020-06-04 18:16:19 +00:00
<div
2020-11-17 15:07:53 +00:00
className={classNames('module-ongoing-call__header', controlsFadeClass)}
2020-06-04 18:16:19 +00:00
>
2020-11-17 15:07:53 +00:00
<CallingHeader
i18n={i18n}
isInSpeakerView={isInSpeakerView}
isGroupCall={isGroupCall}
2020-11-23 21:37:39 +00:00
message={headerMessage}
participantCount={participantCount}
2020-11-20 19:39:50 +00:00
showParticipantsList={showParticipantsList}
2020-11-23 21:37:39 +00:00
title={headerTitle}
2020-11-17 15:07:53 +00:00
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
2021-01-08 22:57:54 +00:00
toggleSpeakerView={toggleSpeakerView}
2020-11-17 15:07:53 +00:00
/>
2020-06-04 18:16:19 +00:00
</div>
2020-11-17 15:07:53 +00:00
{remoteParticipantsElement}
2021-06-02 17:42:41 +00:00
{isSendingVideo && isLonelyInGroup ? (
<div className="module-ongoing-call__local-preview-fullsize">
<video
className={localPreviewVideoClass}
ref={localVideoRef}
autoPlay
/>
</div>
) : null}
2021-06-02 17:42:41 +00:00
{!isSendingVideo && isLonelyInGroup ? (
<div className="module-ongoing-call__local-preview-fullsize">
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
<Avatar
2021-05-07 22:21:10 +00:00
acceptedMessageRequest
avatarPath={me.avatarPath}
2021-08-06 00:17:05 +00:00
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
2021-05-07 22:21:10 +00:00
isMe
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
2021-05-07 22:21:10 +00:00
// `sharedGroupNames` makes no sense for yourself, but `<Avatar>` needs it
// to determine blurring.
sharedGroupNames={[]}
size={80}
/>
<div className="module-calling__camera-is-off">
{i18n('calling__your-video-is-off')}
</div>
</CallBackgroundBlur>
</div>
) : null}
<div className="module-ongoing-call__footer">
{/* This layout-only element is not ideal.
See the comment in _modules.css for more. */}
<div className="module-ongoing-call__footer__local-preview-offset" />
<div
className={classNames(
'module-ongoing-call__footer__actions',
controlsFadeClass
)}
>
2021-08-19 01:04:38 +00:00
<CallingButton
buttonType={presentingButtonType}
i18n={i18n}
onClick={togglePresenting}
/>
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onClick={toggleVideo}
/>
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onClick={toggleAudio}
/>
<CallingButton
buttonType={CallingButtonType.HANG_UP}
i18n={i18n}
onClick={() => {
hangUp({ conversationId: conversation.id });
}}
/>
</div>
<div
className={classNames('module-ongoing-call__footer__local-preview', {
'module-ongoing-call__footer__local-preview--audio-muted': !hasLocalAudio,
})}
>
2021-06-02 17:42:41 +00:00
{isSendingVideo && !isLonelyInGroup ? (
<video
className={localPreviewVideoClass}
ref={localVideoRef}
autoPlay
/>
) : null}
2021-06-02 17:42:41 +00:00
{!isSendingVideo && !isLonelyInGroup ? (
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
<Avatar
2021-05-07 22:21:10 +00:00
acceptedMessageRequest
avatarPath={me.avatarPath}
2021-08-06 00:17:05 +00:00
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
2021-05-07 22:21:10 +00:00
isMe
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
2021-05-07 22:21:10 +00:00
// See comment above about `sharedGroupNames`.
sharedGroupNames={[]}
size={80}
/>
</CallBackgroundBlur>
) : null}
</div>
2020-06-04 18:16:19 +00:00
</div>
</div>
);
};
2020-06-04 18:16:19 +00:00
2020-11-13 19:57:55 +00:00
function getCallModeClassSuffix(
callMode: CallMode.Direct | CallMode.Group
): string {
switch (callMode) {
case CallMode.Direct:
return 'direct';
case CallMode.Group:
return 'group';
default:
throw missingCaseError(callMode);
}
}
2020-06-04 18:16:19 +00:00
function renderHeaderMessage(
i18n: LocalizerType,
callState: CallState,
acceptedDuration: null | number
2020-11-23 21:37:39 +00:00
): string | undefined {
let message;
if (callState === CallState.Prering) {
message = i18n('outgoingCallPrering');
} else if (callState === CallState.Ringing) {
message = i18n('outgoingCallRinging');
} else if (callState === CallState.Reconnecting) {
message = i18n('callReconnecting');
} else if (callState === CallState.Accepted && acceptedDuration) {
message = i18n('callDuration', [renderDuration(acceptedDuration)]);
2020-06-04 18:16:19 +00:00
}
2020-11-23 21:37:39 +00:00
return message;
}
2020-06-04 18:16:19 +00:00
function renderDuration(ms: number): string {
const secs = Math.floor((ms / 1000) % 60)
.toString()
.padStart(2, '0');
const mins = Math.floor((ms / 60000) % 60)
.toString()
.padStart(2, '0');
const hours = Math.floor(ms / 3600000);
if (hours > 0) {
return `${hours}:${mins}:${secs}`;
2020-06-04 18:16:19 +00:00
}
return `${mins}:${secs}`;
2020-06-04 18:16:19 +00:00
}