Calling: Picture-in-picture
This commit is contained in:
parent
7b15bddfc9
commit
a581f6ea81
13 changed files with 467 additions and 3 deletions
|
@ -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'),
|
||||
};
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
45
ts/components/CallingPip.stories.tsx
Normal file
45
ts/components/CallingPip.stories.tsx
Normal 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} />;
|
||||
});
|
241
ts/components/CallingPip.tsx
Normal file
241
ts/components/CallingPip.tsx
Normal 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>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue