336 lines
9.6 KiB
TypeScript
336 lines
9.6 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React from 'react';
|
|
import { minBy, debounce, noop } from 'lodash';
|
|
import type { VideoFrameSource } from '@signalapp/ringrtc';
|
|
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
|
|
import type { LocalizerType } from '../types/Util';
|
|
import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling';
|
|
import type {
|
|
SetLocalPreviewType,
|
|
SetRendererCanvasType,
|
|
} from '../state/ducks/calling';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
|
|
|
|
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;
|
|
hasLocalVideo: boolean;
|
|
i18n: LocalizerType;
|
|
setGroupCallVideoRequest: (
|
|
_: Array<GroupCallVideoRequest>,
|
|
speakerHeight: number
|
|
) => void;
|
|
setLocalPreview: (_: SetLocalPreviewType) => void;
|
|
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
|
switchToPresentationView: () => void;
|
|
switchFromPresentationView: () => void;
|
|
togglePip: () => void;
|
|
};
|
|
|
|
const PIP_HEIGHT = 156;
|
|
const PIP_WIDTH = 120;
|
|
const PIP_TOP_MARGIN = 56;
|
|
const PIP_PADDING = 8;
|
|
|
|
export function CallingPip({
|
|
activeCall,
|
|
getGroupCallVideoFrameSource,
|
|
hangUpActiveCall,
|
|
hasLocalVideo,
|
|
i18n,
|
|
setGroupCallVideoRequest,
|
|
setLocalPreview,
|
|
setRendererCanvas,
|
|
switchToPresentationView,
|
|
switchFromPresentationView,
|
|
togglePip,
|
|
}: PropsType): JSX.Element {
|
|
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
|
|
|
const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
|
|
const localVideoRef = React.useRef(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,
|
|
});
|
|
|
|
useActivateSpeakerViewOnPresenting({
|
|
remoteParticipants: activeCall.remoteParticipants,
|
|
switchToPresentationView,
|
|
switchFromPresentationView,
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
setLocalPreview({ element: localVideoRef });
|
|
}, [setLocalPreview]);
|
|
|
|
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,
|
|
}));
|
|
}
|
|
},
|
|
[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 + PIP_WIDTH);
|
|
distanceToRightEdge = offsetX;
|
|
} else {
|
|
distanceToLeftEdge = offsetX;
|
|
distanceToRightEdge = innerWidth - (offsetX + PIP_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 + PIP_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 + PIP_WIDTH) : offsetX,
|
|
});
|
|
break;
|
|
default:
|
|
throw missingCaseError(snapTo.mode);
|
|
}
|
|
}
|
|
}, [isRTL, positionState, setPositionState]);
|
|
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
const [translateX, translateY] = React.useMemo<[number, number]>(() => {
|
|
switch (positionState.mode) {
|
|
case PositionMode.BeingDragged:
|
|
return [
|
|
isRTL
|
|
? windowWidth -
|
|
positionState.mouseX -
|
|
(PIP_WIDTH - positionState.dragOffsetX)
|
|
: positionState.mouseX - positionState.dragOffsetX,
|
|
positionState.mouseY - positionState.dragOffsetY,
|
|
];
|
|
case PositionMode.SnapToLeft:
|
|
return [
|
|
PIP_PADDING,
|
|
Math.min(
|
|
positionState.offsetY,
|
|
windowHeight - PIP_PADDING - PIP_HEIGHT
|
|
),
|
|
];
|
|
case PositionMode.SnapToRight:
|
|
return [
|
|
windowWidth - PIP_PADDING - PIP_WIDTH,
|
|
Math.min(
|
|
positionState.offsetY,
|
|
windowHeight - PIP_PADDING - PIP_HEIGHT
|
|
),
|
|
];
|
|
case PositionMode.SnapToTop:
|
|
return [
|
|
Math.min(
|
|
positionState.offsetX,
|
|
windowWidth - PIP_PADDING - PIP_WIDTH
|
|
),
|
|
PIP_TOP_MARGIN + PIP_PADDING,
|
|
];
|
|
case PositionMode.SnapToBottom:
|
|
return [
|
|
Math.min(
|
|
positionState.offsetX,
|
|
windowWidth - PIP_PADDING - PIP_WIDTH
|
|
),
|
|
windowHeight - PIP_PADDING - PIP_HEIGHT,
|
|
];
|
|
default:
|
|
throw missingCaseError(positionState);
|
|
}
|
|
}, [isRTL, windowWidth, windowHeight, positionState]);
|
|
const localizedTranslateX = isRTL ? -translateX : translateX;
|
|
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
<div
|
|
className="module-calling-pip"
|
|
onMouseDown={ev => {
|
|
const node = videoContainerRef.current;
|
|
if (!node) {
|
|
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={{
|
|
cursor:
|
|
positionState.mode === PositionMode.BeingDragged
|
|
? '-webkit-grabbing'
|
|
: '-webkit-grab',
|
|
transform: `translate3d(${localizedTranslateX}px,calc(${translateY}px - var(--titlebar-height)), 0)`,
|
|
transition:
|
|
positionState.mode === PositionMode.BeingDragged
|
|
? 'none'
|
|
: 'transform ease-out 300ms',
|
|
}}
|
|
>
|
|
<CallingPipRemoteVideo
|
|
activeCall={activeCall}
|
|
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
|
i18n={i18n}
|
|
setRendererCanvas={setRendererCanvas}
|
|
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
|
/>
|
|
{hasLocalVideo ? (
|
|
<video
|
|
className="module-calling-pip__video--local"
|
|
ref={localVideoRef}
|
|
autoPlay
|
|
/>
|
|
) : null}
|
|
<div className="module-calling-pip__actions">
|
|
<button
|
|
aria-label={i18n('icu:calling__hangup')}
|
|
className="module-calling-pip__button--hangup"
|
|
onClick={hangUp}
|
|
type="button"
|
|
/>
|
|
<button
|
|
aria-label={i18n('icu:calling__pip--off')}
|
|
className="module-calling-pip__button--pip"
|
|
onClick={togglePip}
|
|
type="button"
|
|
>
|
|
<div />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|