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 @@ +collapse-24 \ 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 @@ +expand-24 \ 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 ? ( +
+ ); +}; 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" },