Calling: Picture-in-picture

This commit is contained in:
Josh Perez 2020-09-30 20:43:05 -04:00 committed by Josh Perez
parent 7b15bddfc9
commit a581f6ea81
13 changed files with 467 additions and 3 deletions

View File

@ -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"

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>collapse-24</title><polygon points="15.38 9.68 21.53 3.53 20.47 2.47 14.32 8.62 14.5 7.52 14.5 3 13 3 13 11 21 11 21 9.5 16.48 9.5 15.38 9.68"/><polygon points="3 13 3 14.5 7.52 14.5 8.62 14.32 2.47 20.47 3.53 21.53 9.68 15.38 9.5 16.48 9.5 21 11 21 11 13 3 13"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>expand-24</title><polygon points="14 2 14 3.5 18.52 3.5 19.62 3.32 13.47 9.47 14.53 10.53 20.68 4.38 20.5 5.48 20.5 10 22 10 22 2 14 2"/><polygon points="4.38 20.68 10.53 14.53 9.47 13.47 3.32 19.62 3.5 18.52 3.5 14 2 14 2 22 10 22 10 20.5 5.48 20.5 4.38 20.68"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -84,3 +84,7 @@
font-size: large;
}
}
.call-manager-wrapper {
position: relative;
}

View File

@ -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 {

View File

@ -33,12 +33,14 @@ const defaultProps = {
hasLocalVideo: true,
hasRemoteVideo: true,
i18n,
pip: false,
renderDeviceSelection: () => <div />,
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'),
};

View File

@ -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 (
<CallingPip
callDetails={callDetails}
hangUp={hangUp}
hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
togglePip={togglePip}
/>
);
}
return (
<>
<CallScreen
@ -60,6 +79,7 @@ export const CallManager = ({
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}

View File

@ -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'),
};

View File

@ -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<PropsType, StateType> {
hasLocalVideo,
hasRemoteVideo,
i18n,
togglePip,
toggleSettings,
} = this.props;
const { showControls } = this.state;
@ -256,6 +258,14 @@ export class CallScreen extends React.Component<PropsType, StateType> {
onClick={toggleSettings}
/>
</div>
<div className="module-ongoing-call__pip">
<button
type="button"
aria-label={i18n('calling__pip')}
className="module-ongoing-call__pip--button"
onClick={togglePip}
/>
</div>
</div>
{hasRemoteVideo
? this.renderRemoteVideo()

View File

@ -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> = {}): 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 <CallingPip {...props} />;
});

View File

@ -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 (
<div className="module-calling-pip__video--remote">
<div
className="module-calling-pip__video--background"
style={backgroundStyle}
/>
<div className="module-calling-pip__video--blur" />
<div className="module-calling-pip__video--avatar">
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={52}
/>
</div>
</div>
);
}
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<string, number> = {
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
<div
className="module-calling-pip"
onMouseDown={ev => {
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 ? (
<canvas
className="module-calling-pip__video--remote"
ref={remoteVideoRef}
/>
) : (
renderAvatar(callDetails, i18n)
)}
{hasLocalVideo ? (
<video
className="module-calling-pip__video--local"
ref={localVideoRef}
autoPlay
/>
) : null}
<div className="module-calling-pip__actions">
<button
type="button"
aria-label={i18n('calling__hangup')}
className="module-calling-pip__button--hangup"
onClick={() => {
hangUp({ callId: callDetails.callId });
}}
/>
<button
type="button"
aria-label={i18n('calling__pip')}
className="module-calling-pip__button--pip"
onClick={togglePip}
/>
</div>
</div>
);
};

View File

@ -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;
}

View File

@ -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"
},