Merge pull request #5952 from SimonBrandner/fix/17130/draggable-pip
This commit is contained in:
commit
e9600e9f57
9 changed files with 269 additions and 18 deletions
|
@ -262,6 +262,7 @@
|
||||||
@import "./views/voip/_CallContainer.scss";
|
@import "./views/voip/_CallContainer.scss";
|
||||||
@import "./views/voip/_CallView.scss";
|
@import "./views/voip/_CallView.scss";
|
||||||
@import "./views/voip/_CallViewForRoom.scss";
|
@import "./views/voip/_CallViewForRoom.scss";
|
||||||
|
@import "./views/voip/_CallPreview.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";
|
||||||
|
|
|
@ -30,8 +30,8 @@ limitations under the License.
|
||||||
pointer-events: initial; // restore pointer events so the user can leave/interact
|
pointer-events: initial; // restore pointer events so the user can leave/interact
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.mx_CallView_video {
|
.mx_VideoFeed_remote.mx_VideoFeed_voice {
|
||||||
width: 350px;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_VideoFeed_local {
|
.mx_VideoFeed_local {
|
||||||
|
|
21
res/css/views/voip/_CallPreview.scss
Normal file
21
res/css/views/voip/_CallPreview.scss
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
|
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_CallPreview {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
|
@ -39,7 +39,6 @@ limitations under the License.
|
||||||
.mx_CallView_pip {
|
.mx_CallView_pip {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
margin-top: 10px;
|
|
||||||
background-color: $voipcall-plinth-color;
|
background-color: $voipcall-plinth-color;
|
||||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
@ -15,8 +15,6 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_VideoFeed_voice {
|
.mx_VideoFeed_voice {
|
||||||
// We don't want to collide with the call controls that have 52px of height
|
|
||||||
padding-bottom: 52px;
|
|
||||||
background-color: $inverted-bg-color;
|
background-color: $inverted-bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { createRef } from 'react';
|
||||||
|
|
||||||
import CallView from "./CallView";
|
import CallView from "./CallView";
|
||||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
|
@ -27,6 +27,22 @@ 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';
|
||||||
|
|
||||||
|
const PIP_VIEW_WIDTH = 336;
|
||||||
|
const PIP_VIEW_HEIGHT = 232;
|
||||||
|
|
||||||
|
const MOVING_AMT = 0.2;
|
||||||
|
const SNAPPING_AMT = 0.05;
|
||||||
|
|
||||||
|
const PADDING = {
|
||||||
|
top: 58,
|
||||||
|
bottom: 58,
|
||||||
|
left: 76,
|
||||||
|
right: 8,
|
||||||
|
};
|
||||||
|
|
||||||
const SHOW_CALL_IN_STATES = [
|
const SHOW_CALL_IN_STATES = [
|
||||||
CallState.Connected,
|
CallState.Connected,
|
||||||
|
@ -49,6 +65,10 @@ 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
|
||||||
|
@ -91,6 +111,16 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
||||||
private roomStoreToken: any;
|
private roomStoreToken: any;
|
||||||
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);
|
||||||
|
@ -105,12 +135,17 @@ 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.snap);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
@ -118,6 +153,9 @@ 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.snap);
|
||||||
if (this.roomStoreToken) {
|
if (this.roomStoreToken) {
|
||||||
this.roomStoreToken.remove();
|
this.roomStoreToken.remove();
|
||||||
}
|
}
|
||||||
|
@ -125,6 +163,83 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
||||||
SettingsStore.unwatchSetting(this.settingsWatcherRef);
|
SettingsStore.unwatchSetting(this.settingsWatcherRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
private onRoomViewStoreUpdate = (payload) => {
|
private onRoomViewStoreUpdate = (payload) => {
|
||||||
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
||||||
|
|
||||||
|
@ -173,10 +288,52 @@ 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();
|
||||||
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
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 (
|
||||||
<CallView call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} pipMode={true} />
|
<div
|
||||||
|
className="mx_CallPreview"
|
||||||
|
style={style}
|
||||||
|
ref={this.callViewWrapper}
|
||||||
|
>
|
||||||
|
<CallView
|
||||||
|
call={this.state.primaryCall}
|
||||||
|
secondaryCall={this.state.secondaryCall}
|
||||||
|
pipMode={true}
|
||||||
|
onMouseDownOnHeader={this.onStartMoving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,9 @@ interface IProps {
|
||||||
// This is sort of a proxy for a number of things but we currently have no
|
// This is sort of a proxy for a number of things but we currently have no
|
||||||
// need to control those things separately, so this is simpler.
|
// need to control those things separately, so this is simpler.
|
||||||
pipMode?: boolean;
|
pipMode?: boolean;
|
||||||
|
|
||||||
|
// Used for dragging the PiP CallView
|
||||||
|
onMouseDownOnHeader?: (event: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
@ -698,7 +701,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
</span>;
|
</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
header = <div className="mx_CallView_header">
|
header = (
|
||||||
|
<div
|
||||||
|
className="mx_CallView_header"
|
||||||
|
onMouseDown={this.props.onMouseDownOnHeader}
|
||||||
|
>
|
||||||
<AccessibleButton onClick={this.onRoomAvatarClick}>
|
<AccessibleButton onClick={this.onRoomAvatarClick}>
|
||||||
<RoomAvatar room={callRoom} height={32} width={32} />
|
<RoomAvatar room={callRoom} height={32} width={32} />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
@ -710,7 +717,8 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{headerControls}
|
{headerControls}
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
myClassName = 'mx_CallView_pip';
|
myClassName = 'mx_CallView_pip';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
32
src/utils/AnimationUtils.ts
Normal file
32
src/utils/AnimationUtils.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
|
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 { clamp } from "lodash";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method linearly interpolates between two points (start, end). This is
|
||||||
|
* most commonly used to find a point some fraction of the way along a line
|
||||||
|
* between two endpoints (e.g. to move an object gradually between those
|
||||||
|
* points).
|
||||||
|
* @param {number} start the starting point
|
||||||
|
* @param {number} end the ending point
|
||||||
|
* @param {number} amt the interpolant
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function lerp(start: number, end: number, amt: number) {
|
||||||
|
amt = clamp(amt, 0, 1);
|
||||||
|
return (1 - amt) * start + amt * end;
|
||||||
|
}
|
35
test/utils/AnimationUtils-test.ts
Normal file
35
test/utils/AnimationUtils-test.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
|
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 { lerp } from "../../src/utils/AnimationUtils";
|
||||||
|
|
||||||
|
describe("lerp", () => {
|
||||||
|
it("correctly interpolates", () => {
|
||||||
|
expect(lerp(0, 100, 0.5)).toBe(50);
|
||||||
|
expect(lerp(50, 100, 0.5)).toBe(75);
|
||||||
|
expect(lerp(0, 1, 0.1)).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the interpolant", () => {
|
||||||
|
expect(lerp(0, 100, 50)).toBe(100);
|
||||||
|
expect(lerp(0, 100, -50)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative numbers", () => {
|
||||||
|
expect(lerp(-100, 0, 0.5)).toBe(-50);
|
||||||
|
expect(lerp(100, -100, 0.5)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue