mirror of https://github.com/jitsi/jitsi-meet
parent
decbcefbd4
commit
79b7e1641d
@ -0,0 +1,714 @@ |
||||
// @flow
|
||||
|
||||
import React, { Component } from 'react'; |
||||
import { PanResponder, View } from 'react-native'; |
||||
import { connect } from 'react-redux'; |
||||
|
||||
import { storeVideoTransform } from '../../actions'; |
||||
import styles from './styles'; |
||||
|
||||
/** |
||||
* The default/initial transform (= no transform). |
||||
*/ |
||||
const DEFAULT_TRANSFORM = { |
||||
scale: 1, |
||||
translateX: 0, |
||||
translateY: 0 |
||||
}; |
||||
|
||||
/** |
||||
* The minimum scale (magnification) multiplier. 1 is equal to objectFit |
||||
* = 'contain'. |
||||
*/ |
||||
const MIN_SCALE = 1; |
||||
|
||||
/* |
||||
* The max distance from the edge of the screen where we let the user move the |
||||
* view to. This is large enough now to let the user drag the view to a position |
||||
* where no other displayed components cover it (such as filmstrip). If a |
||||
* ViewPort (hint) support is added to the LargeVideo component then this |
||||
* contant will not be necessary anymore. |
||||
*/ |
||||
const MAX_OFFSET = 100; |
||||
|
||||
/** |
||||
* The max allowed scale (magnification) multiplier. |
||||
*/ |
||||
const MAX_SCALE = 5; |
||||
|
||||
/** |
||||
* The length of a minimum movement after which we consider a gesture a move |
||||
* instead of a tap/long tap. |
||||
*/ |
||||
const MOVE_THRESHOLD_DISMISSES_TOUCH = 2; |
||||
|
||||
/** |
||||
* A tap timeout after which we consider a gesture a long tap and will not |
||||
* trigger onPress (unless long tap gesture support is added in the future). |
||||
*/ |
||||
const TAP_TIMEOUT_MS = 400; |
||||
|
||||
/** |
||||
* Type of a transform object this component is capable of handling. |
||||
*/ |
||||
type Transform = { |
||||
scale: number, |
||||
translateX: number, |
||||
translateY: number |
||||
}; |
||||
|
||||
type Props = { |
||||
|
||||
/** |
||||
* The children components of this view. |
||||
*/ |
||||
children: Object, |
||||
|
||||
/** |
||||
* Transformation is only enabled when this flag is true. |
||||
*/ |
||||
enabled: boolean, |
||||
|
||||
/** |
||||
* Function to invoke when a press event is detected. |
||||
*/ |
||||
onPress?: Function, |
||||
|
||||
/** |
||||
* The id of the current stream that is displayed. |
||||
*/ |
||||
streamId: string, |
||||
|
||||
/** |
||||
* Style of the top level transformable view. |
||||
*/ |
||||
style: Object, |
||||
|
||||
/** |
||||
* The stored transforms retreived from Redux to be initially applied |
||||
* to different streams. |
||||
*/ |
||||
_transforms: Object, |
||||
|
||||
/** |
||||
* Action to dispatch when the component is unmounted. |
||||
*/ |
||||
_onUnmount: Function |
||||
}; |
||||
|
||||
type State = { |
||||
|
||||
/** |
||||
* The current (non-transformed) layout of the View. |
||||
*/ |
||||
layout: ?Object, |
||||
|
||||
/** |
||||
* The current transform that is applied. |
||||
*/ |
||||
transform: Transform |
||||
}; |
||||
|
||||
/** |
||||
* An container that captures gestures such as pinch&zoom, touch or move. |
||||
*/ |
||||
class VideoTransform extends Component<Props, State> { |
||||
/** |
||||
* The gesture handler object. |
||||
*/ |
||||
gestureHandlers: PanResponder; |
||||
|
||||
/** |
||||
* The initial distance of the fingers on pinch start. |
||||
*/ |
||||
initialDistance: number; |
||||
|
||||
/** |
||||
* The initial position of the finger on touch start. |
||||
*/ |
||||
initialPosition: { |
||||
x: number, |
||||
y: number |
||||
}; |
||||
|
||||
/** |
||||
* Time of the last tap. |
||||
*/ |
||||
lastTap: number; |
||||
|
||||
/** |
||||
* Constructor of the component. |
||||
* |
||||
* @inheritdoc |
||||
*/ |
||||
constructor(props: Props) { |
||||
super(props); |
||||
|
||||
this.state = { |
||||
layout: null, |
||||
transform: DEFAULT_TRANSFORM |
||||
}; |
||||
|
||||
this._getTransformStyle = this._getTransformStyle.bind(this); |
||||
this._onGesture = this._onGesture.bind(this); |
||||
this._onLayout = this._onLayout.bind(this); |
||||
this._onMoveShouldSetPanResponder |
||||
= this._onMoveShouldSetPanResponder.bind(this); |
||||
this._onPanResponderGrant = this._onPanResponderGrant.bind(this); |
||||
this._onPanResponderMove = this._onPanResponderMove.bind(this); |
||||
this._onPanResponderRelease = this._onPanResponderRelease.bind(this); |
||||
this._onStartShouldSetPanResponder |
||||
= this._onStartShouldSetPanResponder.bind(this); |
||||
} |
||||
|
||||
/** |
||||
* Implements React Component's componentWillMount. |
||||
* |
||||
* @inheritdoc |
||||
*/ |
||||
componentWillMount() { |
||||
this.gestureHandlers = PanResponder.create({ |
||||
onPanResponderGrant: this._onPanResponderGrant, |
||||
onPanResponderMove: this._onPanResponderMove, |
||||
onPanResponderRelease: this._onPanResponderRelease, |
||||
onPanResponderTerminationRequest: () => true, |
||||
onMoveShouldSetPanResponder: this._onMoveShouldSetPanResponder, |
||||
onShouldBlockNativeResponder: () => false, |
||||
onStartShouldSetPanResponder: this._onStartShouldSetPanResponder |
||||
}); |
||||
|
||||
const { streamId } = this.props; |
||||
|
||||
this._restoreTransform(streamId); |
||||
} |
||||
|
||||
/** |
||||
* Implements React Component's componentWillReceiveProps. |
||||
* |
||||
* @inheritdoc |
||||
*/ |
||||
componentWillReceiveProps({ streamId: newStreamId }) { |
||||
if (this.props.streamId !== newStreamId) { |
||||
this._storeTransform(); |
||||
this._restoreTransform(newStreamId); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Implements React Component's componentWillUnmount. |
||||
* |
||||
* @inheritdoc |
||||
*/ |
||||
componentWillUnmount() { |
||||
this._storeTransform(); |
||||
} |
||||
|
||||
/** |
||||
* Renders the empty component that captures the gestures. |
||||
* |
||||
* @inheritdoc |
||||
*/ |
||||
render() { |
||||
const { children, style } = this.props; |
||||
|
||||
return ( |
||||
<View |
||||
onLayout = { this._onLayout } |
||||
pointerEvents = 'box-only' |
||||
style = { [ |
||||
styles.videoTransformedViewContaier, |
||||
style |
||||
] } |
||||
{ ...this.gestureHandlers.panHandlers }> |
||||
<View |
||||
style = { [ |
||||
styles.videoTranformedView, |
||||
this._getTransformStyle() |
||||
] }> |
||||
{ children } |
||||
</View> |
||||
</View> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Calculates the new transformation to be applied by merging the current |
||||
* transform values with the newly received incremental values. |
||||
* |
||||
* @param {Transform} transform - The new transform object. |
||||
* @private |
||||
* @returns {Transform} |
||||
*/ |
||||
_calculateTransformIncrement(transform: Transform) { |
||||
let { |
||||
scale, |
||||
translateX, |
||||
translateY |
||||
} = this.state.transform; |
||||
const { |
||||
scale: newScale, |
||||
translateX: newTranslateX, |
||||
translateY: newTranslateY |
||||
} = transform; |
||||
|
||||
// Note: We don't limit MIN_SCALE here yet, as we need to detect a scale
|
||||
// down gesture even if the scale is already at MIN_SCALE to let the
|
||||
// user return the screen to center with that gesture. Scale is limited
|
||||
// to MIN_SCALE right before it gets applied.
|
||||
scale = Math.min(scale * (newScale || 1), MAX_SCALE); |
||||
|
||||
translateX = translateX + ((newTranslateX || 0) / scale); |
||||
translateY = translateY + ((newTranslateY || 0) / scale); |
||||
|
||||
return { |
||||
scale, |
||||
translateX, |
||||
translateY |
||||
}; |
||||
} |
||||
|
||||
_didMove: Object => boolean |
||||
|
||||
/** |
||||
* Determines if there was large enough movement to be handled. |
||||
* |
||||
* @param {Object} gestureState - The gesture state. |
||||
* @returns {boolean} |
||||
*/ |
||||
_didMove({ dx, dy }) { |
||||
return Math.abs(dx) > MOVE_THRESHOLD_DISMISSES_TOUCH |
||||
|| Math.abs(dy) > MOVE_THRESHOLD_DISMISSES_TOUCH; |
||||
} |
||||
|
||||
_getTouchDistance: Object => number; |
||||
|
||||
/** |
||||
* Calculates the touch distance on a pinch event. |
||||
* |
||||
* @param {Object} evt - The touch event. |
||||
* @private |
||||
* @returns {number} |
||||
*/ |
||||
_getTouchDistance({ nativeEvent: { touches } }) { |
||||
const dx = Math.abs(touches[0].pageX - touches[1].pageX); |
||||
const dy = Math.abs(touches[0].pageY - touches[1].pageY); |
||||
|
||||
return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); |
||||
} |
||||
|
||||
_getTouchPosition: Object => Object |
||||
|
||||
/** |
||||
* Calculates the position of the touch event. |
||||
* |
||||
* @param {Object} evt - The touch event. |
||||
* @private |
||||
* @returns {Object} |
||||
*/ |
||||
_getTouchPosition({ nativeEvent: { touches } }) { |
||||
return { |
||||
x: touches[0].pageX, |
||||
y: touches[0].pageY |
||||
}; |
||||
} |
||||
|
||||
_getTransformStyle: () => Object |
||||
|
||||
/** |
||||
* Generates a transform style object to be used on the component. |
||||
* |
||||
* @returns {{string: Array<{string: number}>}} |
||||
*/ |
||||
_getTransformStyle() { |
||||
const { enabled } = this.props; |
||||
|
||||
if (!enabled) { |
||||
return null; |
||||
} |
||||
|
||||
const { |
||||
scale, |
||||
translateX, |
||||
translateY |
||||
} = this.state.transform; |
||||
|
||||
return { |
||||
transform: [ |
||||
{ scale }, |
||||
{ translateX }, |
||||
{ translateY } |
||||
] |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Limits the move matrix and then applies the transformation to the |
||||
* component (updates state). |
||||
* |
||||
* Note: Points A (top-left) and D (bottom-right) are opposite points of |
||||
* the View rectangle. |
||||
* |
||||
* @param {Transform} transform - The transformation object. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_limitAndApplyTransformation(transform: Transform) { |
||||
const { layout } = this.state; |
||||
|
||||
if (layout) { |
||||
const { scale } = this.state.transform; |
||||
const { scale: newScaleUnlimited } = transform; |
||||
let { |
||||
translateX: newTranslateX, |
||||
translateY: newTranslateY |
||||
} = transform; |
||||
|
||||
// Scale is only limited to MIN_SCALE here to detect downscale
|
||||
// gesture later.
|
||||
const newScale = Math.max(newScaleUnlimited, MIN_SCALE); |
||||
|
||||
// The A and D points of the original View (before transform).
|
||||
const originalLayout = { |
||||
a: { |
||||
x: layout.x, |
||||
y: layout.y |
||||
}, |
||||
d: { |
||||
x: layout.x + layout.width, |
||||
y: layout.y + layout.height |
||||
} |
||||
}; |
||||
|
||||
// The center point (midpoint) of the transformed View.
|
||||
const transformedCenterPoint = { |
||||
x: ((layout.x + layout.width) / 2) + (newTranslateX * newScale), |
||||
y: ((layout.y + layout.height) / 2) + (newTranslateY * newScale) |
||||
}; |
||||
|
||||
// The size of the transformed View.
|
||||
const transformedSize = { |
||||
height: layout.height * newScale, |
||||
width: layout.width * newScale |
||||
}; |
||||
|
||||
// The A and D points of the transformed View.
|
||||
const transformedLayout = { |
||||
a: { |
||||
x: transformedCenterPoint.x - (transformedSize.width / 2), |
||||
y: transformedCenterPoint.y - (transformedSize.height / 2) |
||||
}, |
||||
d: { |
||||
x: transformedCenterPoint.x + (transformedSize.width / 2), |
||||
y: transformedCenterPoint.y + (transformedSize.height / 2) |
||||
} |
||||
}; |
||||
|
||||
let _MAX_OFFSET = MAX_OFFSET; |
||||
|
||||
if (newScaleUnlimited < scale) { |
||||
// This is a negative scale event so we dynamycally reduce the
|
||||
// MAX_OFFSET to get the screen back to the center on
|
||||
// downscaling.
|
||||
_MAX_OFFSET = Math.min(MAX_OFFSET, MAX_OFFSET * (newScale - 1)); |
||||
} |
||||
|
||||
// Correct move matrix if it goes out of the view
|
||||
// too much (_MAX_OFFSET).
|
||||
newTranslateX |
||||
-= Math.max( |
||||
transformedLayout.a.x - originalLayout.a.x - _MAX_OFFSET, |
||||
0); |
||||
newTranslateX |
||||
+= Math.max( |
||||
originalLayout.d.x - transformedLayout.d.x - _MAX_OFFSET, |
||||
0); |
||||
newTranslateY |
||||
-= Math.max( |
||||
transformedLayout.a.y - originalLayout.a.y - _MAX_OFFSET, |
||||
0); |
||||
newTranslateY |
||||
+= Math.max( |
||||
originalLayout.d.y - transformedLayout.d.y - _MAX_OFFSET, |
||||
0); |
||||
|
||||
this.setState({ |
||||
transform: { |
||||
scale: newScale, |
||||
translateX: Math.round(newTranslateX), |
||||
translateY: Math.round(newTranslateY) |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
_onGesture: (string, ?Object | number) => void |
||||
|
||||
/** |
||||
* Handles gestures and converts them to transforms. |
||||
* |
||||
* Currently supported gestures: |
||||
* - scale (punch&zoom-type scale). |
||||
* - move |
||||
* - press. |
||||
* |
||||
* Note: This component supports onPress solely to overcome the problem of |
||||
* not being able to register gestures via the PanResponder due to the fact |
||||
* that the entire Conference component was a single touch responder |
||||
* component in the past (see base/react/.../Container with an onPress |
||||
* event) - and stock touch responder components seem to have exclusive |
||||
* priority in handling touches in React. |
||||
* |
||||
* @param {string} type - The type of the gesture. |
||||
* @param {?Object | number} value - The value of the gesture, if any. |
||||
* @returns {void} |
||||
*/ |
||||
_onGesture(type, value) { |
||||
let transform; |
||||
|
||||
switch (type) { |
||||
case 'move': |
||||
transform = { |
||||
...DEFAULT_TRANSFORM, |
||||
translateX: value.x, |
||||
translateY: value.y |
||||
}; |
||||
break; |
||||
case 'scale': |
||||
transform = { |
||||
...DEFAULT_TRANSFORM, |
||||
scale: value |
||||
}; |
||||
break; |
||||
|
||||
case 'press': { |
||||
const { onPress } = this.props; |
||||
|
||||
typeof onPress === 'function' && onPress(); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
if (transform) { |
||||
this._limitAndApplyTransformation( |
||||
this._calculateTransformIncrement(transform)); |
||||
} |
||||
|
||||
this.lastTap = 0; |
||||
} |
||||
|
||||
_onLayout: Object => void |
||||
|
||||
/** |
||||
* Callback for the onLayout of the component. |
||||
* |
||||
* @param {Object} event - The native props of the onLayout event. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_onLayout({ nativeEvent: { layout: { x, y, width, height } } }) { |
||||
this.setState({ |
||||
layout: { |
||||
x, |
||||
y, |
||||
width, |
||||
height |
||||
} |
||||
}); |
||||
} |
||||
|
||||
_onMoveShouldSetPanResponder: (Object, Object) => boolean |
||||
|
||||
/** |
||||
* Function to decide whether the responder should respond to a move event. |
||||
* |
||||
* @param {Object} evt - The event. |
||||
* @param {Object} gestureState - Gesture state. |
||||
* @private |
||||
* @returns {boolean} |
||||
*/ |
||||
_onMoveShouldSetPanResponder(evt, gestureState) { |
||||
return this.props.enabled |
||||
&& (this._didMove(gestureState) |
||||
|| gestureState.numberActiveTouches === 2); |
||||
} |
||||
|
||||
_onPanResponderGrant: (Object, Object) => void |
||||
|
||||
/** |
||||
* Calculates the initial touch distance. |
||||
* |
||||
* @param {Object} evt - Touch event. |
||||
* @param {Object} gestureState - Gesture state. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_onPanResponderGrant(evt, { numberActiveTouches }) { |
||||
if (numberActiveTouches === 1) { |
||||
this.initialPosition = this._getTouchPosition(evt); |
||||
this.lastTap = Date.now(); |
||||
|
||||
} else if (numberActiveTouches === 2) { |
||||
this.initialDistance = this._getTouchDistance(evt); |
||||
} |
||||
} |
||||
|
||||
_onPanResponderMove: (Object, Object) => void |
||||
|
||||
/** |
||||
* Handles the PanResponder move (touch move) event. |
||||
* |
||||
* @param {Object} evt - Touch event. |
||||
* @param {Object} gestureState - Gesture state. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_onPanResponderMove(evt, gestureState) { |
||||
if (gestureState.numberActiveTouches === 2) { |
||||
// this is a zoom event
|
||||
if ( |
||||
this.initialDistance === undefined |
||||
|| isNaN(this.initialDistance) |
||||
) { |
||||
// there is no initial distance because the user started
|
||||
// with only one finger. We calculate it now.
|
||||
this.initialDistance = this._getTouchDistance(evt); |
||||
} else { |
||||
const distance = this._getTouchDistance(evt); |
||||
const scale = distance / (this.initialDistance || 1); |
||||
|
||||
this.initialDistance = distance; |
||||
|
||||
this._onGesture('scale', scale); |
||||
} |
||||
} else if (gestureState.numberActiveTouches === 1 |
||||
&& isNaN(this.initialDistance) |
||||
&& this._didMove(gestureState)) { |
||||
// this is a move event
|
||||
const position = this._getTouchPosition(evt); |
||||
const move = { |
||||
x: position.x - this.initialPosition.x, |
||||
y: position.y - this.initialPosition.y |
||||
}; |
||||
|
||||
this.initialPosition = position; |
||||
|
||||
this._onGesture('move', move); |
||||
} |
||||
} |
||||
|
||||
_onPanResponderRelease: () => void |
||||
|
||||
/** |
||||
* Handles the PanResponder gesture end event. |
||||
* |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_onPanResponderRelease() { |
||||
if (this.lastTap && Date.now() - this.lastTap < TAP_TIMEOUT_MS) { |
||||
this._onGesture('press'); |
||||
} |
||||
delete this.initialDistance; |
||||
delete this.initialPosition; |
||||
} |
||||
|
||||
_onStartShouldSetPanResponder: () => boolean |
||||
|
||||
/** |
||||
* Function to decide whether the responder should respond to a start |
||||
* (thouch) event. |
||||
* |
||||
* @private |
||||
* @returns {boolean} |
||||
*/ |
||||
_onStartShouldSetPanResponder() { |
||||
return typeof this.props.onPress === 'function'; |
||||
} |
||||
|
||||
/** |
||||
* Restores the last applied transform when the component is mounted, or |
||||
* a new stream is about to be rendered. |
||||
* |
||||
* @param {string} streamId - The stream id to restore transform for. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_restoreTransform(streamId) { |
||||
const { enabled, _transforms } = this.props; |
||||
|
||||
if (enabled) { |
||||
const initialTransform = _transforms[streamId]; |
||||
|
||||
if (initialTransform) { |
||||
this.setState({ |
||||
transform: initialTransform |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Stores/saves the current transform when the component is destroyed, or a |
||||
* new stream is about to be rendered. |
||||
* |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_storeTransform() { |
||||
const { _onUnmount, enabled, streamId } = this.props; |
||||
|
||||
if (enabled) { |
||||
_onUnmount(streamId, this.state.transform); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Maps dispatching of some action to React component props. |
||||
* |
||||
* @param {Function} dispatch - Redux action dispatcher. |
||||
* @private |
||||
* @returns {{ |
||||
* _onUnmount: Function |
||||
* }} |
||||
*/ |
||||
function _mapDispatchToProps(dispatch) { |
||||
return { |
||||
/** |
||||
* Dispatches actions to store the last applied transform to a video. |
||||
* |
||||
* @param {string} streamId - The ID of the stream. |
||||
* @param {Transform} transform - The last applied transform. |
||||
* @private |
||||
* @returns {void} |
||||
*/ |
||||
_onUnmount(streamId, transform) { |
||||
dispatch(storeVideoTransform(streamId, transform)); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Maps (parts of) the redux state to the component's props. |
||||
* |
||||
* @param {Object} state - The redux state. |
||||
* @param {Object} ownProps - The component's own props. |
||||
* @private |
||||
* @returns {{ |
||||
* _transforms: Object |
||||
* }} |
||||
*/ |
||||
function _mapStateToProps(state) { |
||||
return { |
||||
/** |
||||
* The stored transforms retreived from Redux to be initially applied to |
||||
* different streams. |
||||
* |
||||
* @private |
||||
* @type {Object} |
||||
*/ |
||||
_transforms: state['features/base/media'].video.transforms |
||||
}; |
||||
} |
||||
|
||||
export default connect(_mapStateToProps, _mapDispatchToProps)(VideoTransform); |
Loading…
Reference in new issue