// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useState, useRef, useEffect, useCallback } from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import { HangUpType, SetLocalAudioType, SetLocalPreviewType, SetLocalVideoType, SetRendererCanvasType, } from '../state/ducks/calling'; import { Avatar } from './Avatar'; import { CallingHeader } from './CallingHeader'; import { CallingButton, CallingButtonType } from './CallingButton'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { ActiveCallType, CallMode, CallState, GroupCallConnectionState, GroupCallVideoRequest, VideoFrameSource, } from '../types/Calling'; import { ColorType } from '../types/Colors'; import { LocalizerType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; import { GroupCallToastManager } from './GroupCallToastManager'; export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; hangUp: (_: HangUpType) => void; i18n: LocalizerType; joinedAt?: number; me: { avatarPath?: string; color?: ColorType; name?: string; phoneNumber?: string; profileName?: string; title: string; }; setGroupCallVideoRequest: (_: Array) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; toggleParticipants: () => void; togglePip: () => void; toggleSettings: () => void; toggleSpeakerView: () => void; }; export const CallScreen: React.FC = ({ activeCall, getGroupCallVideoFrameSource, hangUp, i18n, joinedAt, me, setGroupCallVideoRequest, setLocalAudio, setLocalVideo, setLocalPreview, setRendererCanvas, stickyControls, toggleParticipants, togglePip, toggleSettings, toggleSpeakerView, }) => { const { conversation, hasLocalAudio, hasLocalVideo, showParticipantsList, } = activeCall; const toggleAudio = useCallback(() => { setLocalAudio({ enabled: !hasLocalAudio, }); }, [setLocalAudio, hasLocalAudio]); const toggleVideo = useCallback(() => { setLocalVideo({ enabled: !hasLocalVideo, }); }, [setLocalVideo, hasLocalVideo]); const [acceptedDuration, setAcceptedDuration] = useState(null); const [showControls, setShowControls] = useState(true); const localVideoRef = useRef(null); useEffect(() => { setLocalPreview({ element: localVideoRef }); return () => { setLocalPreview({ element: undefined }); }; }, [setLocalPreview, setRendererCanvas]); useEffect(() => { if (!joinedAt) { return noop; } // It's really jumpy with a value of 500ms. const interval = setInterval(() => { setAcceptedDuration(Date.now() - joinedAt); }, 100); return clearInterval.bind(null, interval); }, [joinedAt]); useEffect(() => { if (!showControls || stickyControls) { return noop; } const timer = setTimeout(() => { setShowControls(false); }, 5000); return clearInterval.bind(null, timer); }, [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); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [toggleAudio, toggleVideo]); const hasRemoteVideo = activeCall.remoteParticipants.some( remoteParticipant => remoteParticipant.hasRemoteVideo ); let headerMessage: string | undefined; let headerTitle: string | undefined; let isConnected: boolean; let participantCount: number; let remoteParticipantsElement: JSX.Element; switch (activeCall.callMode) { case CallMode.Direct: headerMessage = renderHeaderMessage( i18n, activeCall.callState || CallState.Prering, acceptedDuration ); headerTitle = conversation.title; isConnected = activeCall.callState === CallState.Accepted; participantCount = isConnected ? 2 : 0; remoteParticipantsElement = ( ); break; case CallMode.Group: participantCount = activeCall.remoteParticipants.length + 1; headerMessage = undefined; headerTitle = activeCall.remoteParticipants.length ? undefined : i18n('calling__in-this-call--zero'); isConnected = activeCall.connectionState === GroupCallConnectionState.Connected; remoteParticipantsElement = ( ); break; default: throw missingCaseError(activeCall); } const isLonelyInGroup = activeCall.callMode === CallMode.Group && !activeCall.remoteParticipants.length; const videoButtonType = hasLocalVideo ? CallingButtonType.VIDEO_ON : CallingButtonType.VIDEO_OFF; const audioButtonType = hasLocalAudio ? CallingButtonType.AUDIO_ON : CallingButtonType.AUDIO_OFF; const isAudioOnly = !hasLocalVideo && !hasRemoteVideo; const controlsFadeClass = classNames({ 'module-ongoing-call__controls--fadeIn': (showControls || isAudioOnly) && !isConnected, 'module-ongoing-call__controls--fadeOut': !showControls && !isAudioOnly && isConnected, }); return (
{ setShowControls(true); }} role="group" > {activeCall.callMode === CallMode.Group ? ( ) : null}
{remoteParticipantsElement} {hasLocalVideo && isLonelyInGroup ? (
) : null} {!hasLocalVideo && isLonelyInGroup ? (
{i18n('calling__your-video-is-off')}
) : null}
{/* This layout-only element is not ideal. See the comment in _modules.css for more. */}
{ hangUp({ conversationId: conversation.id }); }} />
{hasLocalVideo && !isLonelyInGroup ? (
); }; function getCallModeClassSuffix( callMode: CallMode.Direct | CallMode.Group ): string { switch (callMode) { case CallMode.Direct: return 'direct'; case CallMode.Group: return 'group'; default: throw missingCaseError(callMode); } } function renderHeaderMessage( i18n: LocalizerType, callState: CallState, acceptedDuration: null | number ): 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)]); } return message; } 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}`; } return `${mins}:${secs}`; }