Prevent calling PiP from going off-screen
This commit is contained in:
parent
688938b5a1
commit
bb5036364e
2 changed files with 181 additions and 78 deletions
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { minBy, debounce, noop } from 'lodash';
|
||||||
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
|
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
import { VideoFrameSource } from '../types/Calling';
|
import { VideoFrameSource } from '../types/Calling';
|
||||||
|
@ -11,6 +12,41 @@ import {
|
||||||
SetLocalPreviewType,
|
SetLocalPreviewType,
|
||||||
SetRendererCanvasType,
|
SetRendererCanvasType,
|
||||||
} from '../state/ducks/calling';
|
} from '../state/ducks/calling';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SnapCandidate {
|
||||||
|
mode:
|
||||||
|
| PositionMode.SnapToBottom
|
||||||
|
| PositionMode.SnapToLeft
|
||||||
|
| PositionMode.SnapToRight
|
||||||
|
| PositionMode.SnapToTop;
|
||||||
|
distanceToEdge: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
activeCall: ActiveCallType;
|
activeCall: ActiveCallType;
|
||||||
|
@ -25,7 +61,7 @@ export type PropsType = {
|
||||||
|
|
||||||
const PIP_HEIGHT = 156;
|
const PIP_HEIGHT = 156;
|
||||||
const PIP_WIDTH = 120;
|
const PIP_WIDTH = 120;
|
||||||
const PIP_DEFAULT_Y = 56;
|
const PIP_TOP_MARGIN = 56;
|
||||||
const PIP_PADDING = 8;
|
const PIP_PADDING = 8;
|
||||||
|
|
||||||
export const CallingPip = ({
|
export const CallingPip = ({
|
||||||
|
@ -41,15 +77,11 @@ export const CallingPip = ({
|
||||||
const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
|
const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
|
||||||
const localVideoRef = React.useRef(null);
|
const localVideoRef = React.useRef(null);
|
||||||
|
|
||||||
const [dragState, setDragState] = React.useState({
|
const [windowWidth, setWindowWidth] = React.useState(window.innerWidth);
|
||||||
offsetX: 0,
|
const [windowHeight, setWindowHeight] = React.useState(window.innerHeight);
|
||||||
|
const [positionState, setPositionState] = React.useState<PositionState>({
|
||||||
|
mode: PositionMode.SnapToRight,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
isDragging: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [dragContainerStyle, setDragContainerStyle] = React.useState({
|
|
||||||
translateX: window.innerWidth - PIP_WIDTH - PIP_PADDING,
|
|
||||||
translateY: PIP_DEFAULT_Y,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -58,82 +90,145 @@ export const CallingPip = ({
|
||||||
|
|
||||||
const handleMouseMove = React.useCallback(
|
const handleMouseMove = React.useCallback(
|
||||||
(ev: MouseEvent) => {
|
(ev: MouseEvent) => {
|
||||||
if (dragState.isDragging) {
|
if (positionState.mode === PositionMode.BeingDragged) {
|
||||||
setDragContainerStyle({
|
setPositionState(oldState => ({
|
||||||
translateX: ev.clientX - dragState.offsetX,
|
...oldState,
|
||||||
translateY: ev.clientY - dragState.offsetY,
|
mouseX: ev.screenX,
|
||||||
});
|
mouseY: ev.screenY,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dragState]
|
[positionState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = React.useCallback(() => {
|
const handleMouseUp = React.useCallback(() => {
|
||||||
if (dragState.isDragging) {
|
if (positionState.mode === PositionMode.BeingDragged) {
|
||||||
const { translateX, translateY } = dragContainerStyle;
|
const { mouseX, mouseY, dragOffsetX, dragOffsetY } = positionState;
|
||||||
const { innerHeight, innerWidth } = window;
|
const { innerHeight, innerWidth } = window;
|
||||||
|
|
||||||
const proximityRatio: Record<string, number> = {
|
const offsetX = mouseX - dragOffsetX;
|
||||||
top: translateY / innerHeight,
|
const offsetY = mouseY - dragOffsetY;
|
||||||
right: (innerWidth - translateX) / innerWidth,
|
|
||||||
bottom: (innerHeight - translateY) / innerHeight,
|
|
||||||
left: translateX / innerWidth,
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapTo = Object.keys(proximityRatio).reduce(
|
const snapCandidates: Array<SnapCandidate> = [
|
||||||
(minKey: string, key: string): string => {
|
{
|
||||||
return proximityRatio[key] < proximityRatio[minKey] ? key : minKey;
|
mode: PositionMode.SnapToLeft,
|
||||||
}
|
distanceToEdge: offsetX,
|
||||||
);
|
},
|
||||||
|
{
|
||||||
|
mode: PositionMode.SnapToRight,
|
||||||
|
distanceToEdge: innerWidth - (offsetX + PIP_WIDTH),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: PositionMode.SnapToTop,
|
||||||
|
distanceToEdge: offsetY - PIP_TOP_MARGIN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: PositionMode.SnapToBottom,
|
||||||
|
distanceToEdge: innerHeight - (offsetY + PIP_HEIGHT),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
setDragState({
|
// This fallback is mostly for TypeScript, because `minBy` says it can return
|
||||||
...dragState,
|
// `undefined`.
|
||||||
isDragging: false,
|
const snapTo =
|
||||||
});
|
minBy(snapCandidates, candidate => candidate.distanceToEdge) ||
|
||||||
|
snapCandidates[0];
|
||||||
|
|
||||||
let nextX = Math.max(
|
switch (snapTo.mode) {
|
||||||
PIP_PADDING,
|
case PositionMode.SnapToLeft:
|
||||||
Math.min(translateX, innerWidth - PIP_WIDTH - PIP_PADDING)
|
case PositionMode.SnapToRight:
|
||||||
);
|
setPositionState({
|
||||||
let nextY = Math.max(
|
mode: snapTo.mode,
|
||||||
PIP_DEFAULT_Y,
|
offsetY,
|
||||||
Math.min(translateY, innerHeight - PIP_HEIGHT - PIP_PADDING)
|
});
|
||||||
);
|
break;
|
||||||
|
case PositionMode.SnapToTop:
|
||||||
if (snapTo === 'top') {
|
case PositionMode.SnapToBottom:
|
||||||
nextY = PIP_DEFAULT_Y;
|
setPositionState({
|
||||||
|
mode: snapTo.mode,
|
||||||
|
offsetX,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw missingCaseError(snapTo.mode);
|
||||||
}
|
}
|
||||||
if (snapTo === 'right') {
|
|
||||||
nextX = innerWidth - PIP_WIDTH - PIP_PADDING;
|
|
||||||
}
|
|
||||||
if (snapTo === 'bottom') {
|
|
||||||
nextY = innerHeight - PIP_HEIGHT - PIP_PADDING;
|
|
||||||
}
|
|
||||||
if (snapTo === 'left') {
|
|
||||||
nextX = PIP_PADDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDragContainerStyle({
|
|
||||||
translateX: nextX,
|
|
||||||
translateY: nextY,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [dragState, dragContainerStyle]);
|
}, [positionState, setPositionState]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (dragState.isDragging) {
|
if (positionState.mode === PositionMode.BeingDragged) {
|
||||||
document.addEventListener('mousemove', handleMouseMove, false);
|
document.addEventListener('mousemove', handleMouseMove, false);
|
||||||
document.addEventListener('mouseup', handleMouseUp, false);
|
document.addEventListener('mouseup', handleMouseUp, false);
|
||||||
} else {
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp, false);
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove, 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 () => {
|
return () => {
|
||||||
document.removeEventListener('mouseup', handleMouseUp, false);
|
window.removeEventListener('resize', handleWindowResize, false);
|
||||||
document.removeEventListener('mousemove', handleMouseMove, false);
|
|
||||||
};
|
};
|
||||||
}, [dragState, handleMouseMove, handleMouseUp]);
|
}, []);
|
||||||
|
|
||||||
|
const [translateX, translateY] = React.useMemo<[number, number]>(() => {
|
||||||
|
switch (positionState.mode) {
|
||||||
|
case PositionMode.BeingDragged:
|
||||||
|
return [
|
||||||
|
positionState.mouseX - positionState.dragOffsetX,
|
||||||
|
positionState.mouseY - positionState.dragOffsetY,
|
||||||
|
];
|
||||||
|
case PositionMode.SnapToLeft:
|
||||||
|
return [
|
||||||
|
PIP_PADDING,
|
||||||
|
Math.min(
|
||||||
|
PIP_TOP_MARGIN + positionState.offsetY,
|
||||||
|
windowHeight - PIP_PADDING - PIP_HEIGHT
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case PositionMode.SnapToRight:
|
||||||
|
return [
|
||||||
|
windowWidth - PIP_PADDING - PIP_WIDTH,
|
||||||
|
Math.min(
|
||||||
|
PIP_TOP_MARGIN + 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);
|
||||||
|
}
|
||||||
|
}, [windowWidth, windowHeight, positionState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
|
@ -145,20 +240,28 @@ export const CallingPip = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = node.getBoundingClientRect();
|
const rect = node.getBoundingClientRect();
|
||||||
const offsetX = ev.clientX - rect.left;
|
const dragOffsetX = ev.screenX - rect.left;
|
||||||
const offsetY = ev.clientY - rect.top;
|
const dragOffsetY = ev.screenY - rect.top;
|
||||||
|
|
||||||
setDragState({
|
setPositionState({
|
||||||
isDragging: true,
|
mode: PositionMode.BeingDragged,
|
||||||
offsetX,
|
mouseX: ev.screenX,
|
||||||
offsetY,
|
mouseY: ev.screenY,
|
||||||
|
dragOffsetX,
|
||||||
|
dragOffsetY,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
ref={videoContainerRef}
|
ref={videoContainerRef}
|
||||||
style={{
|
style={{
|
||||||
cursor: dragState.isDragging ? '-webkit-grabbing' : '-webkit-grab',
|
cursor:
|
||||||
transform: `translate3d(${dragContainerStyle.translateX}px,${dragContainerStyle.translateY}px, 0)`,
|
positionState.mode === PositionMode.BeingDragged
|
||||||
transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms',
|
? '-webkit-grabbing'
|
||||||
|
: '-webkit-grab',
|
||||||
|
transform: `translate3d(${translateX}px,${translateY}px, 0)`,
|
||||||
|
transition:
|
||||||
|
positionState.mode === PositionMode.BeingDragged
|
||||||
|
? 'none'
|
||||||
|
: 'transform ease-out 300ms',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CallingPipRemoteVideo
|
<CallingPipRemoteVideo
|
||||||
|
|
|
@ -14409,7 +14409,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingPip.js",
|
"path": "ts/components/CallingPip.js",
|
||||||
"line": " const videoContainerRef = react_1.default.useRef(null);",
|
"line": " const videoContainerRef = react_1.default.useRef(null);",
|
||||||
"lineNumber": 15,
|
"lineNumber": 25,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Element is measured. Its HTML is not used."
|
"reasonDetail": "Element is measured. Its HTML is not used."
|
||||||
|
@ -14418,7 +14418,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingPip.js",
|
"path": "ts/components/CallingPip.js",
|
||||||
"line": " const localVideoRef = react_1.default.useRef(null);",
|
"line": " const localVideoRef = react_1.default.useRef(null);",
|
||||||
"lineNumber": 16,
|
"lineNumber": 26,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Used to get the local video element for rendering."
|
"reasonDetail": "Used to get the local video element for rendering."
|
||||||
|
@ -14427,7 +14427,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CallingPip.tsx",
|
"path": "ts/components/CallingPip.tsx",
|
||||||
"line": " const localVideoRef = React.useRef(null);",
|
"line": " const localVideoRef = React.useRef(null);",
|
||||||
"lineNumber": 42,
|
"lineNumber": 78,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Used to get the local video element for rendering."
|
"reasonDetail": "Used to get the local video element for rendering."
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue