Merge pull request #5952 from SimonBrandner/fix/17130/draggable-pip
						commit
						e9600e9f57
					
				|  | @ -262,6 +262,7 @@ | |||
| @import "./views/voip/_CallContainer.scss"; | ||||
| @import "./views/voip/_CallView.scss"; | ||||
| @import "./views/voip/_CallViewForRoom.scss"; | ||||
| @import "./views/voip/_CallPreview.scss"; | ||||
| @import "./views/voip/_DialPad.scss"; | ||||
| @import "./views/voip/_DialPadContextMenu.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 | ||||
|         cursor: pointer; | ||||
| 
 | ||||
|         .mx_CallView_video { | ||||
|             width: 350px; | ||||
|         .mx_VideoFeed_remote.mx_VideoFeed_voice { | ||||
|             min-height: 150px; | ||||
|         } | ||||
| 
 | ||||
|         .mx_VideoFeed_local { | ||||
|  |  | |||
|  | @ -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 { | ||||
|     width: 320px; | ||||
|     padding-bottom: 8px; | ||||
|     margin-top: 10px; | ||||
|     background-color: $voipcall-plinth-color; | ||||
|     box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); | ||||
|     border-radius: 8px; | ||||
|  |  | |||
|  | @ -15,8 +15,6 @@ limitations under the License. | |||
| */ | ||||
| 
 | ||||
| .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; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ See the License for the specific language governing permissions and | |||
| limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| import React from 'react'; | ||||
| import React, { createRef } from 'react'; | ||||
| 
 | ||||
| import CallView from "./CallView"; | ||||
| 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 { MatrixClientPeg } from '../../../MatrixClientPeg'; | ||||
| 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 = [ | ||||
|     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
 | ||||
|     // they belong to
 | ||||
|     secondaryCall: MatrixCall; | ||||
| 
 | ||||
|     // Position of the CallPreview
 | ||||
|     translationX: number; | ||||
|     translationY: number; | ||||
| } | ||||
| 
 | ||||
| // 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 dispatcherRef: 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) { | ||||
|         super(props); | ||||
|  | @ -105,12 +135,17 @@ export default class CallPreview extends React.Component<IProps, IState> { | |||
|             roomId, | ||||
|             primaryCall: primaryCall, | ||||
|             secondaryCall: secondaryCalls[0], | ||||
|             translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, | ||||
|             translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     public componentDidMount() { | ||||
|         CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); | ||||
|         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); | ||||
|         MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); | ||||
|     } | ||||
|  | @ -118,6 +153,9 @@ export default class CallPreview extends React.Component<IProps, IState> { | |||
|     public componentWillUnmount() { | ||||
|         CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); | ||||
|         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) { | ||||
|             this.roomStoreToken.remove(); | ||||
|         } | ||||
|  | @ -125,6 +163,83 @@ export default class CallPreview extends React.Component<IProps, IState> { | |||
|         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) => { | ||||
|         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() { | ||||
|         if (this.state.primaryCall) { | ||||
|             const translatePixelsX = this.state.translationX + "px"; | ||||
|             const translatePixelsY = this.state.translationY + "px"; | ||||
|             const style = { | ||||
|                 transform: `translateX(${translatePixelsX})
 | ||||
|                             translateY(${translatePixelsY})`,
 | ||||
|             }; | ||||
| 
 | ||||
|             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
 | ||||
|         // need to control those things separately, so this is simpler.
 | ||||
|         pipMode?: boolean; | ||||
| 
 | ||||
|         // Used for dragging the PiP CallView
 | ||||
|         onMouseDownOnHeader?: (event: React.MouseEvent) => void; | ||||
| } | ||||
| 
 | ||||
| interface IState { | ||||
|  | @ -698,19 +701,24 @@ export default class CallView extends React.Component<IProps, IState> { | |||
|                 </span>; | ||||
|             } | ||||
| 
 | ||||
|             header = <div className="mx_CallView_header"> | ||||
|                 <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} | ||||
|             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> | ||||
|                 {headerControls} | ||||
|             </div>; | ||||
|             ); | ||||
|             myClassName = 'mx_CallView_pip'; | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  | @ -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 New Issue
	
	 Germain
						Germain