Signal-Desktop/ts/components/CallingPip.tsx

595 lines
18 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { minBy, debounce, noop } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import { missingCaseError } from '../util/missingCaseError';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { CallMode } from '../types/CallDisposition';
import { TooltipPlacement } from './Tooltip';
import { CallingButton, CallingButtonType } from './CallingButton';
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import type { LocalizerType } from '../types/Util';
import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling';
import type { SetRendererCanvasType } from '../state/ducks/calling';
import type { CallingImageDataCache } from './CallManager';
import type { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize } from './Avatar';
import { AvatarColors } from '../types/Colors';
enum PositionMode {
BeingDragged,
SnapToBottom,
SnapToLeft,
SnapToRight,
SnapToTop,
}
type PositionState =
| {
mode: PositionMode.BeingDragged;
mouseX: number;
mouseY: number;
dragOffsetX: number;
dragOffsetY: number;
}
| {
mode: PositionMode.SnapToLeft | PositionMode.SnapToRight;
offsetY: number;
}
| {
mode: PositionMode.SnapToTop | PositionMode.SnapToBottom;
offsetX: number;
};
type SnapCandidate = {
mode:
| PositionMode.SnapToBottom
| PositionMode.SnapToLeft
| PositionMode.SnapToRight
| PositionMode.SnapToTop;
distanceToEdge: number;
};
export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
me: Readonly<
Pick<
ConversationType,
| 'avatarUrl'
| 'avatarPlaceholderGradient'
| 'color'
| 'type'
| 'phoneNumber'
| 'profileName'
| 'title'
| 'sharedGroupNames'
>
>;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
) => void;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
toggleAudio: () => void;
togglePip: () => void;
toggleVideo: () => void;
};
const PIP_STARTING_HEIGHT_NORMAL = 286;
const PIP_STARTING_HEIGHT_LARGE = 400;
const LARGE_THRESHOLD = 1200;
export const PIP_WIDTH_NORMAL = 160;
const PIP_WIDTH_LARGE = 224;
const PIP_TOP_MARGIN = 78;
const PIP_PADDING = 8;
// Receiving portrait video will cause the PIP to update to match that video size, but
// we need limits
export const PIP_MINIMUM_HEIGHT_MULTIPLIER = 1.2;
export const PIP_MAXIMUM_HEIGHT_MULTIPLIER = 2;
export function CallingPip({
activeCall,
getGroupCallVideoFrameSource,
hangUpActiveCall,
imageDataCache,
i18n,
me,
setGroupCallVideoRequest,
setLocalPreviewContainer,
setRendererCanvas,
switchToPresentationView,
switchFromPresentationView,
toggleAudio,
togglePip,
toggleVideo,
}: PropsType): JSX.Element {
const isRTL = i18n.getLocaleDirection() === 'rtl';
const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
const [windowWidth, setWindowWidth] = React.useState(window.innerWidth);
const [windowHeight, setWindowHeight] = React.useState(window.innerHeight);
const [positionState, setPositionState] = React.useState<PositionState>({
mode: PositionMode.SnapToRight,
offsetY: PIP_TOP_MARGIN,
});
const isWindowLarge = windowWidth >= LARGE_THRESHOLD;
const [height, setHeight] = React.useState(
isWindowLarge ? PIP_STARTING_HEIGHT_LARGE : PIP_STARTING_HEIGHT_NORMAL
);
const [width, setWidth] = React.useState(
isWindowLarge ? PIP_WIDTH_LARGE : PIP_WIDTH_NORMAL
);
useActivateSpeakerViewOnPresenting({
remoteParticipants: activeCall.remoteParticipants,
switchToPresentationView,
switchFromPresentationView,
});
const hangUp = React.useCallback(() => {
hangUpActiveCall('pip button click');
}, [hangUpActiveCall]);
const handleMouseMove = React.useCallback(
(ev: MouseEvent) => {
if (positionState.mode === PositionMode.BeingDragged) {
setPositionState(oldState => ({
...oldState,
mouseX: ev.clientX,
mouseY: ev.clientY,
}));
ev.preventDefault();
ev.stopPropagation();
}
},
[positionState]
);
const handleMouseUp = React.useCallback(() => {
if (positionState.mode === PositionMode.BeingDragged) {
const { mouseX, mouseY, dragOffsetX, dragOffsetY } = positionState;
const { innerHeight, innerWidth } = window;
const offsetX = mouseX - dragOffsetX;
const offsetY = mouseY - dragOffsetY;
let distanceToLeftEdge: number;
let distanceToRightEdge: number;
if (isRTL) {
distanceToLeftEdge = innerWidth - (offsetX + width);
distanceToRightEdge = offsetX;
} else {
distanceToLeftEdge = offsetX;
distanceToRightEdge = innerWidth - (offsetX + width);
}
const snapCandidates: Array<SnapCandidate> = [
{
mode: PositionMode.SnapToLeft,
distanceToEdge: distanceToLeftEdge,
},
{
mode: PositionMode.SnapToRight,
distanceToEdge: distanceToRightEdge,
},
{
mode: PositionMode.SnapToTop,
distanceToEdge: offsetY - PIP_TOP_MARGIN,
},
{
mode: PositionMode.SnapToBottom,
distanceToEdge: innerHeight - (offsetY + height),
},
];
// This fallback is mostly for TypeScript, because `minBy` says it can return
// `undefined`.
const snapTo =
minBy(snapCandidates, candidate => candidate.distanceToEdge) ||
snapCandidates[0];
switch (snapTo.mode) {
case PositionMode.SnapToLeft:
case PositionMode.SnapToRight:
setPositionState({
mode: snapTo.mode,
offsetY,
});
break;
case PositionMode.SnapToTop:
case PositionMode.SnapToBottom:
setPositionState({
mode: snapTo.mode,
offsetX: isRTL ? innerWidth - (offsetX + width) : offsetX,
});
break;
default:
throw missingCaseError(snapTo.mode);
}
}
}, [height, isRTL, positionState, setPositionState, width]);
React.useEffect(() => {
if (positionState.mode === PositionMode.BeingDragged) {
document.addEventListener('mousemove', handleMouseMove, false);
document.addEventListener('mouseup', handleMouseUp, false);
return () => {
document.removeEventListener('mouseup', handleMouseUp, false);
document.removeEventListener('mousemove', handleMouseMove, false);
};
}
return noop;
}, [positionState.mode, handleMouseMove, handleMouseUp]);
React.useEffect(() => {
const handleWindowResize = debounce(
() => {
setWindowWidth(window.innerWidth);
setWindowHeight(window.innerHeight);
},
100,
{
maxWait: 3000,
}
);
window.addEventListener('resize', handleWindowResize, false);
return () => {
window.removeEventListener('resize', handleWindowResize, false);
};
}, []);
// This only runs when isWindowLarge changes, so we aggressively change height + width
React.useEffect(() => {
if (isWindowLarge) {
setHeight(PIP_STARTING_HEIGHT_LARGE);
setWidth(PIP_WIDTH_LARGE);
} else {
setHeight(PIP_STARTING_HEIGHT_NORMAL);
setWidth(PIP_WIDTH_NORMAL);
}
}, [isWindowLarge, setHeight, setWidth]);
const [translateX, translateY] = React.useMemo<[number, number]>(() => {
const topMin = PIP_TOP_MARGIN;
const bottomMax = windowHeight - PIP_PADDING - height;
const leftScrollPadding = isRTL ? 1 : 0;
const leftMin = PIP_PADDING + leftScrollPadding;
const rightScrollPadding = isRTL ? 0 : 1;
const rightMax = windowWidth - PIP_PADDING - width - rightScrollPadding;
switch (positionState.mode) {
case PositionMode.BeingDragged:
return [
isRTL
? windowWidth -
positionState.mouseX -
(width - positionState.dragOffsetX)
: positionState.mouseX - positionState.dragOffsetX,
positionState.mouseY - positionState.dragOffsetY,
];
case PositionMode.SnapToLeft:
return [
leftMin,
Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
];
case PositionMode.SnapToRight:
return [
rightMax,
Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
];
case PositionMode.SnapToTop:
return [
Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
topMin,
];
case PositionMode.SnapToBottom:
return [
Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
bottomMax,
];
default:
throw missingCaseError(positionState);
}
}, [height, isRTL, width, windowWidth, windowHeight, positionState]);
const localizedTranslateX = isRTL ? -translateX : translateX;
const [showControls, setShowControls] = React.useState(false);
const onMouseEnter = React.useCallback(() => {
setShowControls(true);
}, [setShowControls]);
const onMouseMove = React.useCallback(() => {
setShowControls(true);
}, [setShowControls]);
const [controlsHover, setControlsHover] = React.useState(false);
const onControlsMouseEnter = React.useCallback(() => {
setControlsHover(true);
}, [setControlsHover]);
const onControlsMouseLeave = React.useCallback(() => {
setControlsHover(false);
}, [setControlsHover]);
React.useEffect(() => {
if (!showControls) {
return;
}
if (controlsHover) {
return;
}
const timer = setTimeout(() => {
setShowControls(false);
}, 2000);
return clearTimeout.bind(null, timer);
}, [showControls, controlsHover, setShowControls]);
const localVideoClassName = activeCall.presentingSource
? 'module-calling-pip__video--local-presenting'
: 'module-calling-pip__video--local';
let raisedHandsCount = 0;
let callJoinRequests = 0;
if (isGroupOrAdhocActiveCall(activeCall)) {
raisedHandsCount = activeCall.raisedHands.size;
callJoinRequests = activeCall.pendingParticipants.length;
}
let videoButtonType: CallingButtonType;
if (activeCall.presentingSource) {
videoButtonType = CallingButtonType.VIDEO_DISABLED;
} else if (activeCall.hasLocalVideo) {
videoButtonType = CallingButtonType.VIDEO_ON;
} else {
videoButtonType = CallingButtonType.VIDEO_OFF;
}
const audioButtonType = activeCall.hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const hangupButtonType =
activeCall.callMode === CallMode.Direct
? CallingButtonType.HANGUP_DIRECT
: CallingButtonType.HANGUP_GROUP;
let remoteVideoNode: JSX.Element;
const isLonelyInCall = !activeCall.remoteParticipants.length;
const isSendingVideo =
activeCall.hasLocalVideo || activeCall.presentingSource;
const avatarSize = isWindowLarge
? AvatarSize.NINETY_SIX
: AvatarSize.SIXTY_FOUR;
if (isLonelyInCall) {
remoteVideoNode = (
<div className="module-calling-pip__video--remote">
{isSendingVideo ? (
// TODO: DESKTOP-8537 - when black bars go away, need to make some CSS changes
<>
<CallBackgroundBlur avatarUrl={me.avatarUrl} darken />
<div
className={classNames(
'module-calling-pip__full-size-local-preview',
activeCall.presentingSource
? 'module-calling-pip__full-size-local-preview--presenting'
: undefined
)}
ref={setLocalPreviewContainer}
/>
</>
) : (
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<div className="module-calling-pip__video--avatar">
<Avatar
avatarPlaceholderGradient={me.avatarPlaceholderGradient}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType={me.type}
i18n={i18n}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
size={avatarSize}
sharedGroupNames={[]}
/>
</div>
</CallBackgroundBlur>
)}
</div>
);
} else {
remoteVideoNode = (
<CallingPipRemoteVideo
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
setGroupCallVideoRequest={setGroupCallVideoRequest}
height={height}
width={width}
updateHeight={(newHeight: number) => {
setHeight(newHeight);
}}
/>
);
}
const localVideoWidth = isWindowLarge ? 120 : 80;
const localVideoHeight = isWindowLarge ? 80 : 54;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="module-calling-pip"
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseDown={ev => {
const node = videoContainerRef.current;
if (!node) {
return;
}
const targetNode = ev.target as Element;
if (targetNode?.tagName === 'BUTTON') {
return;
}
const parentNode = targetNode.parentNode as Element;
if (parentNode?.tagName === 'BUTTON') {
return;
}
const rect = node.getBoundingClientRect();
const dragOffsetX = ev.clientX - rect.left;
const dragOffsetY = ev.clientY - rect.top;
setPositionState({
mode: PositionMode.BeingDragged,
mouseX: ev.clientX,
mouseY: ev.clientY,
dragOffsetX,
dragOffsetY,
});
}}
ref={videoContainerRef}
style={{
height: `${height}px`,
width: `${width}px`,
cursor:
positionState.mode === PositionMode.BeingDragged
? '-webkit-grabbing'
: '-webkit-grab',
transform: `translate3d(${localizedTranslateX}px,calc(${translateY}px), 0)`,
transition:
positionState.mode === PositionMode.BeingDragged
? 'none'
: 'transform ease-out 300ms',
}}
>
{remoteVideoNode}
{!isLonelyInCall && activeCall.hasLocalVideo ? (
<div
style={{
height: `${localVideoHeight}px`,
width: `${localVideoWidth}px`,
}}
className={localVideoClassName}
ref={setLocalPreviewContainer}
/>
) : null}
<div
className={classNames(
'module-calling-pip__un-pip-container',
showControls
? 'module-calling-pip__un-pip-container--visible'
: undefined
)}
>
<CallingButton
buttonType={CallingButtonType.FULL_SCREEN_CALL}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={togglePip}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
{raisedHandsCount || callJoinRequests ? (
<div
className={classNames(
'module-calling-pip__pills',
!showControls ? 'module-calling-pip__pills--no-controls' : undefined
)}
>
{raisedHandsCount ? (
<div className="module-calling-pip__pill">
<div
className={classNames(
'module-calling-pip__pill-icon',
'module-calling-pip__pill-icon__raised-hands'
)}
/>
{raisedHandsCount}
</div>
) : undefined}
{callJoinRequests ? (
<div className="module-calling-pip__pill">
<div
className={classNames(
'module-calling-pip__pill-icon',
'module-calling-pip__pill-icon__group-join'
)}
/>
{callJoinRequests}
</div>
) : undefined}
</div>
) : undefined}
<div
className={classNames(
'module-calling-pip__actions',
showControls ? 'module-calling-pip__actions--visible' : undefined
)}
>
<div className="module-calling-pip__actions__spacer" />
<div className="module-calling-pip__actions__button">
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="module-calling-pip__actions__button module-calling-pip__actions__middle-button">
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="module-calling-pip__actions__button">
<CallingButton
buttonType={hangupButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={hangUp}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="module-calling-pip__actions__spacer" />
</div>
</div>
);
}