Merge pull request #6475 from matrix-org/jitsi-picture-in-picture

Refactor Calls components into smaller pieces for future Widgets work
This commit is contained in:
Dariusz Niemczyk 2021-08-06 18:03:30 +02:00 committed by GitHub
commit b6f7c4fe95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 530 additions and 407 deletions

View file

@ -275,6 +275,7 @@
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallViewSidebar.scss"; @import "./views/voip/_CallViewSidebar.scss";
@import "./views/voip/_CallViewHeader.scss";
@import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_DialPadModal.scss";

View file

@ -199,120 +199,6 @@ limitations under the License.
} }
} }
.mx_CallView_header {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
flex-shrink: 0;
cursor: pointer;
}
.mx_CallView_header_callType {
font-size: 1.2rem;
font-weight: bold;
vertical-align: middle;
}
.mx_CallView_header_secondaryCallInfo {
&::before {
content: '·';
margin-left: 6px;
margin-right: 6px;
}
}
.mx_CallView_header_controls {
margin-left: auto;
}
.mx_CallView_header_button {
display: inline-block;
vertical-align: middle;
cursor: pointer;
&::before {
content: '';
display: inline-block;
height: 20px;
width: 20px;
vertical-align: middle;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_CallView_header_button_fullscreen {
&::before {
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
}
}
.mx_CallView_header_button_expand {
&::before {
mask-image: url('$(res)/img/element-icons/call/expand.svg');
}
}
.mx_CallView_header_callInfo {
margin-left: 12px;
margin-right: 16px;
}
.mx_CallView_header_roomName {
font-weight: bold;
font-size: 12px;
line-height: initial;
height: 15px;
}
.mx_CallView_secondaryCall_roomName {
margin-left: 4px;
}
.mx_CallView_header_callTypeSmall {
font-size: 12px;
color: $secondary-fg-color;
line-height: initial;
height: 15px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 240px;
}
.mx_CallView_header_callTypeIcon {
display: inline-block;
margin-right: 6px;
height: 16px;
width: 16px;
vertical-align: middle;
&::before {
content: '';
display: inline-block;
vertical-align: top;
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
&.mx_CallView_header_callTypeIcon_voice::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
&.mx_CallView_header_callTypeIcon_video::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}
.mx_CallView_callControls { .mx_CallView_callControls {
position: absolute; position: absolute;
display: flex; display: flex;

View file

@ -0,0 +1,129 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallViewHeader {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
flex-shrink: 0;
cursor: pointer;
}
.mx_CallViewHeader_callType {
font-size: 1.2rem;
font-weight: bold;
vertical-align: middle;
}
.mx_CallViewHeader_secondaryCallInfo {
&::before {
content: '·';
margin-left: 6px;
margin-right: 6px;
}
}
.mx_CallViewHeader_controls {
margin-left: auto;
}
.mx_CallViewHeader_button {
display: inline-block;
vertical-align: middle;
cursor: pointer;
&::before {
content: '';
display: inline-block;
height: 20px;
width: 20px;
vertical-align: middle;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.mx_CallViewHeader_button_fullscreen {
&::before {
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
}
}
.mx_CallViewHeader_button_expand {
&::before {
mask-image: url('$(res)/img/element-icons/call/expand.svg');
}
}
.mx_CallViewHeader_callInfo {
margin-left: 12px;
margin-right: 16px;
}
.mx_CallViewHeader_roomName {
font-weight: bold;
font-size: 12px;
line-height: initial;
height: 15px;
}
.mx_CallView_secondaryCall_roomName {
margin-left: 4px;
}
.mx_CallViewHeader_callTypeSmall {
font-size: 12px;
color: $secondary-fg-color;
line-height: initial;
height: 15px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 240px;
}
.mx_CallViewHeader_callTypeIcon {
display: inline-block;
margin-right: 6px;
height: 16px;
width: 16px;
vertical-align: middle;
&::before {
content: '';
display: inline-block;
vertical-align: top;
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
&.mx_CallViewHeader_callTypeIcon_voice::before {
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
&.mx_CallViewHeader_callTypeIcon_video::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React from 'react';
import CallView from "./CallView"; import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import { EventSubscription } from 'fbemitter'; import { EventSubscription } from 'fbemitter';
import { PictureInPictureDragger } from './PictureInPictureDragger';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.1;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
const SHOW_CALL_IN_STATES = [ const SHOW_CALL_IN_STATES = [
CallState.Connected, CallState.Connected,
@ -66,10 +51,6 @@ interface IState {
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to // they belong to
secondaryCall: MatrixCall; secondaryCall: MatrixCall;
// Position of the CallPreview
translationX: number;
translationY: number;
} }
// Splits a list of calls into one 'primary' one and a list // Splits a list of calls into one 'primary' one and a list
@ -112,16 +93,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: EventSubscription; private roomStoreToken: EventSubscription;
private dispatcherRef: string; private dispatcherRef: string;
private settingsWatcherRef: string; private settingsWatcherRef: string;
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
private moving = false;
private scheduledUpdate = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -136,17 +107,12 @@ export default class CallPreview extends React.Component<IProps, IState> {
roomId, roomId,
primaryCall: primaryCall, primaryCall: primaryCall,
secondaryCall: secondaryCalls[0], secondaryCall: secondaryCalls[0],
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
}; };
} }
public componentDidMount() { public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.onResize);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
} }
@ -154,9 +120,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.onResize);
if (this.roomStoreToken) { if (this.roomStoreToken) {
this.roomStoreToken.remove(); this.roomStoreToken.remove();
} }
@ -164,94 +127,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.settingsWatcherRef); SettingsStore.unwatchSetting(this.settingsWatcherRef);
} }
private onResize = (): void => {
this.snap(false);
};
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
// loop. If that is the case, it means the PiP has snapped into its
// position and there is nothing to do. Not doing this would cause an
// infinite loop
if (
!this.moving &&
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
) return;
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.setState({
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
});
this.scheduledUpdate.mark();
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private snap(animate?: boolean): void {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
if (animate) {
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
} else {
this.setState({
translationX: this.desiredTranslationX,
translationY: this.desiredTranslationY,
});
}
}
private onRoomViewStoreUpdate = () => { private onRoomViewStoreUpdate = () => {
if (RoomViewStore.getRoomId() === this.state.roomId) return; if (RoomViewStore.getRoomId() === this.state.roomId) return;
@ -269,9 +144,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
switch (payload.action) { switch (payload.action) {
case 'call_state': {
// listen for call state changes to prod the render method, which // listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead // may hide the global CallView if the call it is tracking is dead
case 'call_state': {
this.updateCalls(); this.updateCalls();
break; break;
} }
@ -300,57 +176,26 @@ export default class CallPreview extends React.Component<IProps, IState> {
}); });
}; };
private onStartMoving = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
event.preventDefault();
event.stopPropagation();
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
this.snap(true);
};
public render() { public render() {
const pipMode = true;
if (this.state.primaryCall) { if (this.state.primaryCall) {
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
const style = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})`,
};
return ( return (
<div <PictureInPictureDragger
className="mx_CallPreview" className="mx_CallPreview"
style={style} draggable={pipMode}
ref={this.callViewWrapper}
> >
<CallView { ({ onStartMoving, onResize }) => <CallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall} call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall} secondaryCall={this.state.secondaryCall}
pipMode={true} pipMode={pipMode}
onMouseDownOnHeader={this.onStartMoving} onResize={onResize}
onResize={this.onResize} /> }
/> </PictureInPictureDragger>
</div>
); );
} }
return <PersistentApp />; return <PersistentApp />;
} }
} }

View file

@ -42,6 +42,7 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar'; import CallViewSidebar from './CallViewSidebar';
import { CallViewHeader } from './CallView/CallViewHeader';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Alignment } from "../elements/Tooltip"; import { Alignment } from "../elements/Tooltip";
@ -54,7 +55,7 @@ interface IProps {
// a callback which is called when the content in the CallView changes // a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize. // in a way that is likely to cause a resize.
onResize?: any; onResize?: (event: Event) => void;
// Whether this call view is for picture-in-picture mode // Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in. // otherwise, it's the larger call view when viewing the room the call is in.
@ -63,7 +64,7 @@ interface IProps {
pipMode?: boolean; pipMode?: boolean;
// Used for dragging the PiP CallView // Used for dragging the PiP CallView
onMouseDownOnHeader?: (event: React.MouseEvent) => void; onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
} }
interface IState { interface IState {
@ -239,21 +240,6 @@ export default class CallView extends React.Component<IProps, IState> {
}); });
}; };
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
private onExpandClick = () => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onControlsHideTimer = () => { private onControlsHideTimer = () => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return; if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null; this.controlsHideTimer = null;
@ -397,23 +383,6 @@ export default class CallView extends React.Component<IProps, IState> {
this.setState({ hoveringControls: false }); this.setState({ hoveringControls: false });
}; };
private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onSecondaryRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
});
};
private onCallResumeClick = (): void => { private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
@ -746,7 +715,7 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_voice_hold: isOnHold, mx_CallView_voice_hold: isOnHold,
}); });
contentView =( contentView = (
<div className={classes} onMouseMove={this.onMouseMove}> <div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer"> <div className="mx_CallView_voice_avatarsContainer">
<div <div
@ -848,83 +817,15 @@ export default class CallView extends React.Component<IProps, IState> {
); );
} }
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call"); const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large';
let myClassName;
let fullScreenButton;
if (!this.props.pipMode) {
fullScreenButton = (
<AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick}
title={_t("Fill Screen")}
/>
);
}
let expandButton;
if (this.props.pipMode) {
expandButton = <AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick}
title={_t("Return to call")}
/>;
}
const headerControls = <div className="mx_CallView_header_controls">
{ fullScreenButton }
{ expandButton }
</div>;
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
"mx_CallView_header_callTypeIcon_video": isVideoCall,
});
let header: React.ReactNode;
if (!this.props.pipMode) {
header = <div className="mx_CallView_header">
<div className={callTypeIconClassName} />
<span className="mx_CallView_header_callType">{ callTypeText }</span>
{ headerControls }
</div>;
myClassName = 'mx_CallView_large';
} else {
let secondaryCallInfo;
if (this.props.secondaryCall) {
secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
<RoomAvatar room={secCallRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: secCallRoom.name }) }
</span>
</AccessibleButton>
</span>;
}
header = (
<div
className="mx_CallView_header"
onMouseDown={this.props.onMouseDownOnHeader}
>
<AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div className="mx_CallView_header_callInfo">
<div className="mx_CallView_header_roomName">{ callRoom.name }</div>
<div className="mx_CallView_header_callTypeSmall">
{ callTypeText }
{ secondaryCallInfo }
</div>
</div>
{ headerControls }
</div>
);
myClassName = 'mx_CallView_pip';
}
return <div className={"mx_CallView " + myClassName}> return <div className={"mx_CallView " + myClassName}>
{ header } <CallViewHeader
onPipMouseDown={this.props.onMouseDownOnHeader}
pipMode={this.props.pipMode}
type={this.props.call.type}
callRooms={[callRoom, secCallRoom]}
/>
{ contentView } { contentView }
</div>; </div>;
} }

View file

@ -0,0 +1,132 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CallType } from 'matrix-js-sdk/src/webrtc/call';
import { Room } from 'matrix-js-sdk/src/models/room';
import React from 'react';
import { _t } from '../../../../languageHandler';
import RoomAvatar from '../../avatars/RoomAvatar';
import AccessibleButton from '../../elements/AccessibleButton';
import dis from '../../../../dispatcher/dispatcher';
import classNames from 'classnames';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
const callTypeTranslationByType: Record<CallType, string> = {
[CallType.Video]: _t("Video Call"),
[CallType.Voice]: _t("Voice Call"),
};
interface CallViewHeaderProps {
pipMode: boolean;
type: CallType;
callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
}
const onRoomAvatarClick = (roomId: string) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
};
const onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
};
const onExpandClick = (roomId: string) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
};
type CallControlsProps = Pick<CallViewHeaderProps, 'pipMode' | 'type'> & {
roomId: string;
};
const CallViewHeaderControls: React.FC<CallControlsProps> = ({ pipMode = false, type, roomId }) => {
return <div className="mx_CallViewHeader_controls">
{ (pipMode && type === CallType.Video) &&
<AccessibleTooltipButton className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
onClick={onFullscreenClick}
title={_t("Fill Screen")}
/> }
{ pipMode && <AccessibleTooltipButton className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
onClick={() => onExpandClick(roomId)}
title={_t("Return to call")}
/> }
</div>;
};
const SecondaryCallInfo: React.FC<{ callRoom: Room }> = ({ callRoom }) => {
return <span className="mx_CallViewHeader_secondaryCallInfo">
<AccessibleButton element='span' onClick={() => onRoomAvatarClick(callRoom.roomId)}>
<RoomAvatar room={callRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: callRoom.name }) }
</span>
</AccessibleButton>
</span>;
};
const CallTypeIcon: React.FC<{ type: CallType }> = ({ type }) => {
const classes = classNames({
'mx_CallViewHeader_callTypeIcon': true,
'mx_CallViewHeader_callTypeIcon_video': type === CallType.Video,
'mx_CallViewHeader_callTypeIcon_voice': type === CallType.Voice,
});
return <div className={classes} />;
};
export const CallViewHeader: React.FC<CallViewHeaderProps> = ({
type,
pipMode = false,
callRooms = [],
onPipMouseDown,
}) => {
const [callRoom, onHoldCallRoom] = callRooms;
const callTypeText = callTypeTranslationByType[type];
const callRoomName = callRoom.name;
const { roomId } = callRoom;
if (!pipMode) {
return <div className="mx_CallViewHeader">
<CallTypeIcon type={type} />
<span className="mx_CallViewHeader_callType">{ callTypeText }</span>
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
</div>;
}
return (
<div
className="mx_CallViewHeader"
onMouseDown={onPipMouseDown}
>
<AccessibleButton onClick={() => onRoomAvatarClick(roomId)}>
<RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton>
<div className="mx_CallViewHeader_callInfo">
<div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
<div className="mx_CallViewHeader_callTypeSmall">
{ callTypeText }
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
</div>
</div>
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
</div>
);
};

View file

@ -0,0 +1,229 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import { replaceableComponent } from '../../../utils/replaceableComponent';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.1;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
interface IChildrenOptions {
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
onResize: (event: Event) => void;
}
interface IProps {
className?: string;
children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode;
draggable: boolean;
}
interface IState {
// Position of the PictureInPictureDragger
translationX: number;
translationY: number;
}
/**
* PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
*/
@replaceableComponent("views.voip.PictureInPictureDragger")
export class PictureInPictureDragger extends React.Component<IProps, IState> {
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
private moving = false;
private scheduledUpdate = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props: IProps) {
super(props);
this.state = {
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
};
}
public componentDidMount() {
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.onResize);
}
public componentWillUnmount() {
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.onResize);
}
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
// loop. If that is the case, it means the PiP has snapped into its
// position and there is nothing to do. Not doing this would cause an
// infinite loop
if (
!this.moving &&
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
) return;
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.setState({
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
});
this.scheduledUpdate.mark();
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private onResize = (): void => {
this.snap(false);
};
private snap = (animate = false) => {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
if (animate) {
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
} else {
this.setState({
translationX: this.desiredTranslationX,
translationY: this.desiredTranslationY,
});
}
};
private onStartMoving = (event: React.MouseEvent | MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
event.preventDefault();
event.stopPropagation();
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
this.snap(true);
};
public render() {
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
const style = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})`,
};
return (
<div
className={this.props.className}
style={this.props.draggable ? style : undefined}
ref={this.callViewWrapper}
>
<>
{ this.props.children({
onStartMoving: this.onStartMoving,
onResize: this.onResize,
}) }
</>
</div>
);
}
}