From a581f6ea811478f5cf41b06363f4fec5ad862a4e Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Wed, 30 Sep 2020 20:43:05 -0400
Subject: [PATCH] Calling: Picture-in-picture
---
_locales/en/messages.json | 8 +
images/icons/v2/collapse-24.svg | 1 +
images/icons/v2/expand-24.svg | 1 +
stylesheets/_index.scss | 4 +
stylesheets/_modules.scss | 111 +++++++++++-
ts/components/CallManager.stories.tsx | 2 +
ts/components/CallManager.tsx | 20 +++
ts/components/CallScreen.stories.tsx | 1 +
ts/components/CallScreen.tsx | 10 ++
ts/components/CallingPip.stories.tsx | 45 +++++
ts/components/CallingPip.tsx | 241 ++++++++++++++++++++++++++
ts/state/ducks/calling.ts | 22 +++
ts/util/lint/exceptions.json | 4 +-
13 files changed, 467 insertions(+), 3 deletions(-)
create mode 100644 images/icons/v2/collapse-24.svg
create mode 100644 images/icons/v2/expand-24.svg
create mode 100644 ts/components/CallingPip.stories.tsx
create mode 100644 ts/components/CallingPip.tsx
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index e65adc6ed0..bdf9e0e59b 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -2863,6 +2863,14 @@
"message": "Settings",
"description": "Title for device selection settings"
},
+ "calling__pip": {
+ "message": "Picture-in-picture",
+ "description": "Title for picture-in-picture toggle"
+ },
+ "calling__hangup": {
+ "message": "Hang Up",
+ "description": "Title for hang up button"
+ },
"callingDeviceSelection__label--video": {
"message": "Video",
"description": "Label for video input selector"
diff --git a/images/icons/v2/collapse-24.svg b/images/icons/v2/collapse-24.svg
new file mode 100644
index 0000000000..4c3f9a9bf8
--- /dev/null
+++ b/images/icons/v2/collapse-24.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/icons/v2/expand-24.svg b/images/icons/v2/expand-24.svg
new file mode 100644
index 0000000000..0b378af102
--- /dev/null
+++ b/images/icons/v2/expand-24.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss
index e4f87a37c3..6bb8f2cd17 100644
--- a/stylesheets/_index.scss
+++ b/stylesheets/_index.scss
@@ -84,3 +84,7 @@
font-size: large;
}
}
+
+.call-manager-wrapper {
+ position: relative;
+}
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index 8785060830..99734e108e 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -6133,7 +6133,7 @@ button.module-image__border-overlay:focus {
.module-ongoing-call__settings {
position: absolute;
top: 25px;
- right: 25px;
+ right: 65px;
&--button {
@include color-svg(
@@ -6145,6 +6145,115 @@ button.module-image__border-overlay:focus {
}
}
+.module-ongoing-call__pip {
+ position: absolute;
+ top: 25px;
+ right: 25px;
+
+ &--button {
+ @include color-svg('../images/icons/v2/collapse-24.svg', $color-white);
+ height: 22px;
+ width: 22px;
+ }
+}
+
+.module-calling-pip {
+ backface-visibility: hidden;
+ background-color: $color-gray-95;
+ border-radius: 4px;
+ box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.05), 0px 8px 20px rgba(0, 0, 0, 0.3);
+ cursor: grab;
+ height: 158px;
+ position: absolute;
+ width: 120px;
+ z-index: 2;
+
+ &__video {
+ &--remote {
+ align-items: center;
+ background-color: $color-gray-95;
+ border-radius: 4px 4px 0 0;
+ display: flex;
+ height: 120px;
+ justify-content: center;
+ position: relative;
+ width: 100%;
+ }
+
+ &--local {
+ bottom: 38px;
+ height: 32px;
+ position: absolute;
+ right: 4px;
+ width: 32px;
+ }
+
+ &--background {
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 4px 4px 0 0;
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ }
+
+ &--blur {
+ backdrop-filter: blur(7px);
+ backface-visibility: hidden;
+ background-color: $color-black-alpha-40;
+ border-radius: 4px 4px 0 0;
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ }
+
+ &--avatar img {
+ -webkit-user-drag: none;
+ -webkit-user-select: none;
+ }
+ }
+
+ &__actions {
+ align-items: center;
+ background-color: $color-gray-02;
+ border-radius: 0 0 4px 4px;
+ display: flex;
+ flex-direction: row;
+ height: 38px;
+ justify-content: space-around;
+
+ @include dark-theme {
+ background-color: $color-gray-65;
+ }
+ }
+
+ &__button {
+ &--hangup {
+ @include color-svg(
+ '../images/icons/v2/phone-down-28.svg',
+ $color-gray-75
+ );
+ height: 28px;
+ width: 28px;
+ @include dark-theme {
+ @include color-svg(
+ '../images/icons/v2/phone-down-28.svg',
+ $color-gray-15
+ );
+ }
+ }
+
+ &--pip {
+ @include color-svg('../images/icons/v2/expand-24.svg', $color-gray-75);
+ height: 24px;
+ width: 24px;
+ @include dark-theme {
+ @include color-svg('../images/icons/v2/expand-24.svg', $color-gray-15);
+ }
+ }
+ }
+}
+
// Module: Left Pane
.module-left-pane {
diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx
index ac0b504e84..6d6c0d291a 100644
--- a/ts/components/CallManager.stories.tsx
+++ b/ts/components/CallManager.stories.tsx
@@ -33,12 +33,14 @@ const defaultProps = {
hasLocalVideo: true,
hasRemoteVideo: true,
i18n,
+ pip: false,
renderDeviceSelection: () =>
,
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'),
settingsDialogOpen: false,
+ togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
};
diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx
index 494c9a9b85..811c73fa56 100644
--- a/ts/components/CallManager.tsx
+++ b/ts/components/CallManager.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { CallingPip } from './CallingPip';
import { CallScreen, PropsType as CallScreenPropsType } from './CallScreen';
import {
IncomingCallBar,
@@ -10,6 +11,7 @@ import { CallDetailsType } from '../state/ducks/calling';
type CallManagerPropsType = {
callDetails?: CallDetailsType;
callState?: CallState;
+ pip: boolean;
renderDeviceSelection: () => JSX.Element;
settingsDialogOpen: boolean;
};
@@ -28,12 +30,14 @@ export const CallManager = ({
hasLocalVideo,
hasRemoteVideo,
i18n,
+ pip,
renderDeviceSelection,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setRendererCanvas,
settingsDialogOpen,
+ togglePip,
toggleSettings,
}: PropsType): JSX.Element | null => {
if (!callDetails || !callState) {
@@ -46,6 +50,21 @@ export const CallManager = ({
const ringing = callState === CallState.Ringing;
if (outgoing || ongoing) {
+ if (pip) {
+ return (
+
+ );
+ }
+
return (
<>
{settingsDialogOpen && renderDeviceSelection()}
diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx
index 3eeda54f36..6672ba9d9a 100644
--- a/ts/components/CallScreen.stories.tsx
+++ b/ts/components/CallScreen.stories.tsx
@@ -36,6 +36,7 @@ const defaultProps = {
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'),
+ togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
};
diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx
index f43e88ed1f..eefe29e99d 100644
--- a/ts/components/CallScreen.tsx
+++ b/ts/components/CallScreen.tsx
@@ -45,6 +45,7 @@ export type PropsType = {
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
+ togglePip: () => void;
toggleSettings: () => void;
};
@@ -209,6 +210,7 @@ export class CallScreen extends React.Component {
hasLocalVideo,
hasRemoteVideo,
i18n,
+ togglePip,
toggleSettings,
} = this.props;
const { showControls } = this.state;
@@ -256,6 +258,14 @@ export class CallScreen extends React.Component {
onClick={toggleSettings}
/>
+
+
+
{hasRemoteVideo
? this.renderRemoteVideo()
diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx
new file mode 100644
index 0000000000..908a49397b
--- /dev/null
+++ b/ts/components/CallingPip.stories.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react';
+import { storiesOf } from '@storybook/react';
+import { boolean } from '@storybook/addon-knobs';
+import { action } from '@storybook/addon-actions';
+
+import { ColorType } from '../types/Colors';
+import { CallingPip, PropsType } from './CallingPip';
+import { setup as setupI18n } from '../../js/modules/i18n';
+import enMessages from '../../_locales/en/messages.json';
+
+const i18n = setupI18n('en', enMessages);
+
+const callDetails = {
+ callId: 0,
+ isIncoming: true,
+ isVideoCall: true,
+
+ avatarPath: undefined,
+ color: 'ultramarine' as ColorType,
+ title: 'Rick Sanchez',
+ name: 'Rick Sanchez',
+ phoneNumber: '3051234567',
+ profileName: 'Rick Sanchez',
+};
+
+const createProps = (overrideProps: Partial = {}): PropsType => ({
+ callDetails,
+ hangUp: action('hang-up'),
+ hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
+ hasRemoteVideo: boolean(
+ 'hasRemoteVideo',
+ overrideProps.hasRemoteVideo || false
+ ),
+ i18n,
+ setLocalPreview: action('set-local-preview'),
+ setRendererCanvas: action('set-renderer-canvas'),
+ togglePip: action('toggle-pip'),
+});
+
+const story = storiesOf('Components/CallingPip', module);
+
+story.add('Default', () => {
+ const props = createProps();
+ return ;
+});
diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx
new file mode 100644
index 0000000000..edb2791004
--- /dev/null
+++ b/ts/components/CallingPip.tsx
@@ -0,0 +1,241 @@
+import React from 'react';
+import {
+ CallDetailsType,
+ HangUpType,
+ SetLocalPreviewType,
+ SetRendererCanvasType,
+} from '../state/ducks/calling';
+import { Avatar } from './Avatar';
+import { LocalizerType } from '../types/Util';
+
+function renderAvatar(
+ callDetails: CallDetailsType,
+ i18n: LocalizerType
+): JSX.Element {
+ const {
+ avatarPath,
+ color,
+ name,
+ phoneNumber,
+ profileName,
+ title,
+ } = callDetails;
+
+ const backgroundStyle = avatarPath
+ ? {
+ backgroundImage: `url("${avatarPath}")`,
+ }
+ : {
+ backgroundColor: color,
+ };
+
+ return (
+
+ );
+}
+
+export type PropsType = {
+ callDetails: CallDetailsType;
+ hangUp: (_: HangUpType) => void;
+ hasLocalVideo: boolean;
+ hasRemoteVideo: boolean;
+ i18n: LocalizerType;
+ setLocalPreview: (_: SetLocalPreviewType) => void;
+ setRendererCanvas: (_: SetRendererCanvasType) => void;
+ togglePip: () => void;
+};
+
+const PIP_HEIGHT = 156;
+const PIP_WIDTH = 120;
+const PIP_DEFAULT_Y = 56;
+const PIP_PADDING = 8;
+
+export const CallingPip = ({
+ callDetails,
+ hangUp,
+ hasLocalVideo,
+ hasRemoteVideo,
+ i18n,
+ setLocalPreview,
+ setRendererCanvas,
+ togglePip,
+}: PropsType): JSX.Element | null => {
+ const videoContainerRef = React.useRef(null);
+ const localVideoRef = React.useRef(null);
+ const remoteVideoRef = React.useRef(null);
+
+ const [dragState, setDragState] = React.useState({
+ offsetX: 0,
+ offsetY: 0,
+ isDragging: false,
+ });
+
+ const [dragContainerStyle, setDragContainerStyle] = React.useState({
+ translateX: window.innerWidth - PIP_WIDTH - PIP_PADDING,
+ translateY: PIP_DEFAULT_Y,
+ });
+
+ React.useEffect(() => {
+ setLocalPreview({ element: localVideoRef });
+ setRendererCanvas({ element: remoteVideoRef });
+ }, [setLocalPreview, setRendererCanvas]);
+
+ const handleMouseMove = React.useCallback(
+ (ev: MouseEvent) => {
+ if (dragState.isDragging) {
+ setDragContainerStyle({
+ translateX: ev.clientX - dragState.offsetX,
+ translateY: ev.clientY - dragState.offsetY,
+ });
+ }
+ },
+ [dragState]
+ );
+
+ const handleMouseUp = React.useCallback(() => {
+ if (dragState.isDragging) {
+ const { translateX, translateY } = dragContainerStyle;
+ const { innerHeight, innerWidth } = window;
+
+ const proximityRatio: Record = {
+ top: translateY / innerHeight,
+ right: (innerWidth - translateX) / innerWidth,
+ bottom: (innerHeight - translateY) / innerHeight,
+ left: translateX / innerWidth,
+ };
+
+ const snapTo = Object.keys(proximityRatio).reduce(
+ (minKey: string, key: string): string => {
+ return proximityRatio[key] < proximityRatio[minKey] ? key : minKey;
+ }
+ );
+
+ setDragState({
+ ...dragState,
+ isDragging: false,
+ });
+
+ let nextX = Math.max(
+ PIP_PADDING,
+ Math.min(translateX, innerWidth - PIP_WIDTH - PIP_PADDING)
+ );
+ let nextY = Math.max(
+ PIP_DEFAULT_Y,
+ Math.min(translateY, innerHeight - PIP_HEIGHT - PIP_PADDING)
+ );
+
+ if (snapTo === 'top') {
+ nextY = PIP_DEFAULT_Y;
+ }
+ 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]);
+
+ React.useEffect(() => {
+ if (dragState.isDragging) {
+ document.addEventListener('mousemove', handleMouseMove, 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);
+ };
+ }, [dragState, handleMouseMove, handleMouseUp]);
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+ {
+ const node = videoContainerRef.current;
+ if (!node) {
+ return;
+ }
+ const rect = (node as HTMLElement).getBoundingClientRect();
+ const offsetX = ev.clientX - rect.left;
+ const offsetY = ev.clientY - rect.top;
+
+ setDragState({
+ isDragging: true,
+ offsetX,
+ offsetY,
+ });
+ }}
+ ref={videoContainerRef}
+ style={{
+ cursor: dragState.isDragging ? '-webkit-grabbing' : '-webkit-grab',
+ transform: `translate3d(${dragContainerStyle.translateX}px,${dragContainerStyle.translateY}px, 0)`,
+ transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms',
+ }}
+ >
+ {hasRemoteVideo ? (
+
+ ) : (
+ renderAvatar(callDetails, i18n)
+ )}
+ {hasLocalVideo ? (
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts
index 5cacbb6b39..5e041063f1 100644
--- a/ts/state/ducks/calling.ts
+++ b/ts/state/ducks/calling.ts
@@ -38,6 +38,7 @@ export type CallingStateType = MediaDeviceSettings & {
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
+ pip: boolean;
settingsDialogOpen: boolean;
};
@@ -105,6 +106,7 @@ const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO = 'calling/SET_LOCAL_AUDIO';
const SET_LOCAL_VIDEO = 'calling/SET_LOCAL_VIDEO';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
+const TOGGLE_PIP = 'calling/TOGGLE_PIP';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
type AcceptCallActionType = {
@@ -177,6 +179,10 @@ type SetLocalVideoFulfilledActionType = {
payload: SetLocalVideoType;
};
+type TogglePipActionType = {
+ type: 'calling/TOGGLE_PIP';
+};
+
type ToggleSettingsActionType = {
type: 'calling/TOGGLE_SETTINGS';
};
@@ -196,6 +202,7 @@ export type CallingActionType =
| SetLocalAudioActionType
| SetLocalVideoActionType
| SetLocalVideoFulfilledActionType
+ | TogglePipActionType
| ToggleSettingsActionType;
// Action Creators
@@ -376,6 +383,12 @@ function setLocalVideo(payload: SetLocalVideoType): SetLocalVideoActionType {
};
}
+function togglePip(): TogglePipActionType {
+ return {
+ type: TOGGLE_PIP,
+ };
+}
+
function toggleSettings(): ToggleSettingsActionType {
return {
type: TOGGLE_SETTINGS,
@@ -410,6 +423,7 @@ export const actions = {
setRendererCanvas,
setLocalAudio,
setLocalVideo,
+ togglePip,
toggleSettings,
};
@@ -427,6 +441,7 @@ function getEmptyState(): CallingStateType {
hasLocalAudio: false,
hasLocalVideo: false,
hasRemoteVideo: false,
+ pip: false,
selectedCamera: undefined,
selectedMicrophone: undefined,
selectedSpeaker: undefined,
@@ -545,5 +560,12 @@ export function reducer(
};
}
+ if (action.type === TOGGLE_PIP) {
+ return {
+ ...state,
+ pip: !state.pip,
+ };
+ }
+
return state;
}
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 76ba09ad1f..7e62c43c6e 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -12863,7 +12863,7 @@
"rule": "React-createRef",
"path": "ts/components/CallScreen.tsx",
"line": " this.localVideoRef = React.createRef();",
- "lineNumber": 79,
+ "lineNumber": 80,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Used to render local preview video"
@@ -12872,7 +12872,7 @@
"rule": "React-createRef",
"path": "ts/components/CallScreen.tsx",
"line": " this.remoteVideoRef = React.createRef();",
- "lineNumber": 80,
+ "lineNumber": 81,
"reasonCategory": "usageTrusted",
"updated": "2020-09-14T23:03:44.863Z"
},