// 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; me: Readonly< Pick< ConversationType, | 'avatarUrl' | 'avatarPlaceholderGradient' | 'color' | 'type' | 'phoneNumber' | 'profileName' | 'title' | 'sharedGroupNames' > >; setGroupCallVideoRequest: ( _: Array, 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); const [windowWidth, setWindowWidth] = React.useState(window.innerWidth); const [windowHeight, setWindowHeight] = React.useState(window.innerHeight); const [positionState, setPositionState] = React.useState({ 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 = [ { 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 = (
{isSendingVideo ? ( // TODO: DESKTOP-8537 - when black bars go away, need to make some CSS changes <>
) : (
)}
); } else { remoteVideoNode = ( { setHeight(newHeight); }} /> ); } const localVideoWidth = isWindowLarge ? 120 : 80; const localVideoHeight = isWindowLarge ? 80 : 54; return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{ 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 ? (
) : null}
{raisedHandsCount || callJoinRequests ? (
{raisedHandsCount ? (
{raisedHandsCount}
) : undefined} {callJoinRequests ? (
{callJoinRequests}
) : undefined}
) : undefined}
); }