You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

VideoTransform.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. // @flow
  2. import React, { Component } from 'react';
  3. import { PanResponder, View } from 'react-native';
  4. import { connect } from 'react-redux';
  5. import { storeVideoTransform } from '../../actions';
  6. import styles from './styles';
  7. /**
  8. * The default/initial transform (= no transform).
  9. */
  10. const DEFAULT_TRANSFORM = {
  11. scale: 1,
  12. translateX: 0,
  13. translateY: 0
  14. };
  15. /**
  16. * The minimum scale (magnification) multiplier. 1 is equal to objectFit
  17. * = 'contain'.
  18. */
  19. const MIN_SCALE = 1;
  20. /*
  21. * The max distance from the edge of the screen where we let the user move the
  22. * view to. This is large enough now to let the user drag the view to a position
  23. * where no other displayed components cover it (such as filmstrip). If a
  24. * ViewPort (hint) support is added to the LargeVideo component then this
  25. * contant will not be necessary anymore.
  26. */
  27. const MAX_OFFSET = 100;
  28. /**
  29. * The max allowed scale (magnification) multiplier.
  30. */
  31. const MAX_SCALE = 5;
  32. /**
  33. * The length of a minimum movement after which we consider a gesture a move
  34. * instead of a tap/long tap.
  35. */
  36. const MOVE_THRESHOLD_DISMISSES_TOUCH = 2;
  37. /**
  38. * A tap timeout after which we consider a gesture a long tap and will not
  39. * trigger onPress (unless long tap gesture support is added in the future).
  40. */
  41. const TAP_TIMEOUT_MS = 400;
  42. /**
  43. * Type of a transform object this component is capable of handling.
  44. */
  45. type Transform = {
  46. scale: number,
  47. translateX: number,
  48. translateY: number
  49. };
  50. type Props = {
  51. /**
  52. * The children components of this view.
  53. */
  54. children: Object,
  55. /**
  56. * Transformation is only enabled when this flag is true.
  57. */
  58. enabled: boolean,
  59. /**
  60. * Function to invoke when a press event is detected.
  61. */
  62. onPress?: Function,
  63. /**
  64. * The id of the current stream that is displayed.
  65. */
  66. streamId: string,
  67. /**
  68. * Style of the top level transformable view.
  69. */
  70. style: Object,
  71. /**
  72. * The stored transforms retreived from Redux to be initially applied
  73. * to different streams.
  74. */
  75. _transforms: Object,
  76. /**
  77. * Action to dispatch when the component is unmounted.
  78. */
  79. _onUnmount: Function
  80. };
  81. type State = {
  82. /**
  83. * The current (non-transformed) layout of the View.
  84. */
  85. layout: ?Object,
  86. /**
  87. * The current transform that is applied.
  88. */
  89. transform: Transform
  90. };
  91. /**
  92. * An container that captures gestures such as pinch&zoom, touch or move.
  93. */
  94. class VideoTransform extends Component<Props, State> {
  95. /**
  96. * The gesture handler object.
  97. */
  98. gestureHandlers: PanResponder;
  99. /**
  100. * The initial distance of the fingers on pinch start.
  101. */
  102. initialDistance: number;
  103. /**
  104. * The initial position of the finger on touch start.
  105. */
  106. initialPosition: {
  107. x: number,
  108. y: number
  109. };
  110. /**
  111. * Time of the last tap.
  112. */
  113. lastTap: number;
  114. /**
  115. * Constructor of the component.
  116. *
  117. * @inheritdoc
  118. */
  119. constructor(props: Props) {
  120. super(props);
  121. this.state = {
  122. layout: null,
  123. transform: DEFAULT_TRANSFORM
  124. };
  125. this._getTransformStyle = this._getTransformStyle.bind(this);
  126. this._onGesture = this._onGesture.bind(this);
  127. this._onLayout = this._onLayout.bind(this);
  128. this._onMoveShouldSetPanResponder
  129. = this._onMoveShouldSetPanResponder.bind(this);
  130. this._onPanResponderGrant = this._onPanResponderGrant.bind(this);
  131. this._onPanResponderMove = this._onPanResponderMove.bind(this);
  132. this._onPanResponderRelease = this._onPanResponderRelease.bind(this);
  133. this._onStartShouldSetPanResponder
  134. = this._onStartShouldSetPanResponder.bind(this);
  135. }
  136. /**
  137. * Implements React Component's componentWillMount.
  138. *
  139. * @inheritdoc
  140. */
  141. componentWillMount() {
  142. this.gestureHandlers = PanResponder.create({
  143. onPanResponderGrant: this._onPanResponderGrant,
  144. onPanResponderMove: this._onPanResponderMove,
  145. onPanResponderRelease: this._onPanResponderRelease,
  146. onPanResponderTerminationRequest: () => true,
  147. onMoveShouldSetPanResponder: this._onMoveShouldSetPanResponder,
  148. onShouldBlockNativeResponder: () => false,
  149. onStartShouldSetPanResponder: this._onStartShouldSetPanResponder
  150. });
  151. const { streamId } = this.props;
  152. this._restoreTransform(streamId);
  153. }
  154. /**
  155. * Implements React Component's componentWillReceiveProps.
  156. *
  157. * @inheritdoc
  158. */
  159. componentWillReceiveProps({ streamId: newStreamId }) {
  160. if (this.props.streamId !== newStreamId) {
  161. this._storeTransform();
  162. this._restoreTransform(newStreamId);
  163. }
  164. }
  165. /**
  166. * Implements React Component's componentWillUnmount.
  167. *
  168. * @inheritdoc
  169. */
  170. componentWillUnmount() {
  171. this._storeTransform();
  172. }
  173. /**
  174. * Renders the empty component that captures the gestures.
  175. *
  176. * @inheritdoc
  177. */
  178. render() {
  179. const { children, style } = this.props;
  180. return (
  181. <View
  182. onLayout = { this._onLayout }
  183. pointerEvents = 'box-only'
  184. style = { [
  185. styles.videoTransformedViewContaier,
  186. style
  187. ] }
  188. { ...this.gestureHandlers.panHandlers }>
  189. <View
  190. style = { [
  191. styles.videoTranformedView,
  192. this._getTransformStyle()
  193. ] }>
  194. { children }
  195. </View>
  196. </View>
  197. );
  198. }
  199. /**
  200. * Calculates the new transformation to be applied by merging the current
  201. * transform values with the newly received incremental values.
  202. *
  203. * @param {Transform} transform - The new transform object.
  204. * @private
  205. * @returns {Transform}
  206. */
  207. _calculateTransformIncrement(transform: Transform) {
  208. let {
  209. scale,
  210. translateX,
  211. translateY
  212. } = this.state.transform;
  213. const {
  214. scale: newScale,
  215. translateX: newTranslateX,
  216. translateY: newTranslateY
  217. } = transform;
  218. // Note: We don't limit MIN_SCALE here yet, as we need to detect a scale
  219. // down gesture even if the scale is already at MIN_SCALE to let the
  220. // user return the screen to center with that gesture. Scale is limited
  221. // to MIN_SCALE right before it gets applied.
  222. scale = Math.min(scale * (newScale || 1), MAX_SCALE);
  223. translateX = translateX + ((newTranslateX || 0) / scale);
  224. translateY = translateY + ((newTranslateY || 0) / scale);
  225. return {
  226. scale,
  227. translateX,
  228. translateY
  229. };
  230. }
  231. _didMove: Object => boolean
  232. /**
  233. * Determines if there was large enough movement to be handled.
  234. *
  235. * @param {Object} gestureState - The gesture state.
  236. * @returns {boolean}
  237. */
  238. _didMove({ dx, dy }) {
  239. return Math.abs(dx) > MOVE_THRESHOLD_DISMISSES_TOUCH
  240. || Math.abs(dy) > MOVE_THRESHOLD_DISMISSES_TOUCH;
  241. }
  242. _getTouchDistance: Object => number;
  243. /**
  244. * Calculates the touch distance on a pinch event.
  245. *
  246. * @param {Object} evt - The touch event.
  247. * @private
  248. * @returns {number}
  249. */
  250. _getTouchDistance({ nativeEvent: { touches } }) {
  251. const dx = Math.abs(touches[0].pageX - touches[1].pageX);
  252. const dy = Math.abs(touches[0].pageY - touches[1].pageY);
  253. return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
  254. }
  255. _getTouchPosition: Object => Object
  256. /**
  257. * Calculates the position of the touch event.
  258. *
  259. * @param {Object} evt - The touch event.
  260. * @private
  261. * @returns {Object}
  262. */
  263. _getTouchPosition({ nativeEvent: { touches } }) {
  264. return {
  265. x: touches[0].pageX,
  266. y: touches[0].pageY
  267. };
  268. }
  269. _getTransformStyle: () => Object
  270. /**
  271. * Generates a transform style object to be used on the component.
  272. *
  273. * @returns {{string: Array<{string: number}>}}
  274. */
  275. _getTransformStyle() {
  276. const { enabled } = this.props;
  277. if (!enabled) {
  278. return null;
  279. }
  280. const {
  281. scale,
  282. translateX,
  283. translateY
  284. } = this.state.transform;
  285. return {
  286. transform: [
  287. { scale },
  288. { translateX },
  289. { translateY }
  290. ]
  291. };
  292. }
  293. /**
  294. * Limits the move matrix and then applies the transformation to the
  295. * component (updates state).
  296. *
  297. * Note: Points A (top-left) and D (bottom-right) are opposite points of
  298. * the View rectangle.
  299. *
  300. * @param {Transform} transform - The transformation object.
  301. * @private
  302. * @returns {void}
  303. */
  304. _limitAndApplyTransformation(transform: Transform) {
  305. const { layout } = this.state;
  306. if (layout) {
  307. const { scale } = this.state.transform;
  308. const { scale: newScaleUnlimited } = transform;
  309. let {
  310. translateX: newTranslateX,
  311. translateY: newTranslateY
  312. } = transform;
  313. // Scale is only limited to MIN_SCALE here to detect downscale
  314. // gesture later.
  315. const newScale = Math.max(newScaleUnlimited, MIN_SCALE);
  316. // The A and D points of the original View (before transform).
  317. const originalLayout = {
  318. a: {
  319. x: layout.x,
  320. y: layout.y
  321. },
  322. d: {
  323. x: layout.x + layout.width,
  324. y: layout.y + layout.height
  325. }
  326. };
  327. // The center point (midpoint) of the transformed View.
  328. const transformedCenterPoint = {
  329. x: ((layout.x + layout.width) / 2) + (newTranslateX * newScale),
  330. y: ((layout.y + layout.height) / 2) + (newTranslateY * newScale)
  331. };
  332. // The size of the transformed View.
  333. const transformedSize = {
  334. height: layout.height * newScale,
  335. width: layout.width * newScale
  336. };
  337. // The A and D points of the transformed View.
  338. const transformedLayout = {
  339. a: {
  340. x: transformedCenterPoint.x - (transformedSize.width / 2),
  341. y: transformedCenterPoint.y - (transformedSize.height / 2)
  342. },
  343. d: {
  344. x: transformedCenterPoint.x + (transformedSize.width / 2),
  345. y: transformedCenterPoint.y + (transformedSize.height / 2)
  346. }
  347. };
  348. let _MAX_OFFSET = MAX_OFFSET;
  349. if (newScaleUnlimited < scale) {
  350. // This is a negative scale event so we dynamycally reduce the
  351. // MAX_OFFSET to get the screen back to the center on
  352. // downscaling.
  353. _MAX_OFFSET = Math.min(MAX_OFFSET, MAX_OFFSET * (newScale - 1));
  354. }
  355. // Correct move matrix if it goes out of the view
  356. // too much (_MAX_OFFSET).
  357. newTranslateX
  358. -= Math.max(
  359. transformedLayout.a.x - originalLayout.a.x - _MAX_OFFSET,
  360. 0);
  361. newTranslateX
  362. += Math.max(
  363. originalLayout.d.x - transformedLayout.d.x - _MAX_OFFSET,
  364. 0);
  365. newTranslateY
  366. -= Math.max(
  367. transformedLayout.a.y - originalLayout.a.y - _MAX_OFFSET,
  368. 0);
  369. newTranslateY
  370. += Math.max(
  371. originalLayout.d.y - transformedLayout.d.y - _MAX_OFFSET,
  372. 0);
  373. this.setState({
  374. transform: {
  375. scale: newScale,
  376. translateX: Math.round(newTranslateX),
  377. translateY: Math.round(newTranslateY)
  378. }
  379. });
  380. }
  381. }
  382. _onGesture: (string, ?Object | number) => void
  383. /**
  384. * Handles gestures and converts them to transforms.
  385. *
  386. * Currently supported gestures:
  387. * - scale (punch&zoom-type scale).
  388. * - move
  389. * - press.
  390. *
  391. * Note: This component supports onPress solely to overcome the problem of
  392. * not being able to register gestures via the PanResponder due to the fact
  393. * that the entire Conference component was a single touch responder
  394. * component in the past (see base/react/.../Container with an onPress
  395. * event) - and stock touch responder components seem to have exclusive
  396. * priority in handling touches in React.
  397. *
  398. * @param {string} type - The type of the gesture.
  399. * @param {?Object | number} value - The value of the gesture, if any.
  400. * @returns {void}
  401. */
  402. _onGesture(type, value) {
  403. let transform;
  404. switch (type) {
  405. case 'move':
  406. transform = {
  407. ...DEFAULT_TRANSFORM,
  408. translateX: value.x,
  409. translateY: value.y
  410. };
  411. break;
  412. case 'scale':
  413. transform = {
  414. ...DEFAULT_TRANSFORM,
  415. scale: value
  416. };
  417. break;
  418. case 'press': {
  419. const { onPress } = this.props;
  420. typeof onPress === 'function' && onPress();
  421. break;
  422. }
  423. }
  424. if (transform) {
  425. this._limitAndApplyTransformation(
  426. this._calculateTransformIncrement(transform));
  427. }
  428. this.lastTap = 0;
  429. }
  430. _onLayout: Object => void
  431. /**
  432. * Callback for the onLayout of the component.
  433. *
  434. * @param {Object} event - The native props of the onLayout event.
  435. * @private
  436. * @returns {void}
  437. */
  438. _onLayout({ nativeEvent: { layout: { x, y, width, height } } }) {
  439. this.setState({
  440. layout: {
  441. x,
  442. y,
  443. width,
  444. height
  445. }
  446. });
  447. }
  448. _onMoveShouldSetPanResponder: (Object, Object) => boolean
  449. /**
  450. * Function to decide whether the responder should respond to a move event.
  451. *
  452. * @param {Object} evt - The event.
  453. * @param {Object} gestureState - Gesture state.
  454. * @private
  455. * @returns {boolean}
  456. */
  457. _onMoveShouldSetPanResponder(evt, gestureState) {
  458. return this.props.enabled
  459. && (this._didMove(gestureState)
  460. || gestureState.numberActiveTouches === 2);
  461. }
  462. _onPanResponderGrant: (Object, Object) => void
  463. /**
  464. * Calculates the initial touch distance.
  465. *
  466. * @param {Object} evt - Touch event.
  467. * @param {Object} gestureState - Gesture state.
  468. * @private
  469. * @returns {void}
  470. */
  471. _onPanResponderGrant(evt, { numberActiveTouches }) {
  472. if (numberActiveTouches === 1) {
  473. this.initialPosition = this._getTouchPosition(evt);
  474. this.lastTap = Date.now();
  475. } else if (numberActiveTouches === 2) {
  476. this.initialDistance = this._getTouchDistance(evt);
  477. }
  478. }
  479. _onPanResponderMove: (Object, Object) => void
  480. /**
  481. * Handles the PanResponder move (touch move) event.
  482. *
  483. * @param {Object} evt - Touch event.
  484. * @param {Object} gestureState - Gesture state.
  485. * @private
  486. * @returns {void}
  487. */
  488. _onPanResponderMove(evt, gestureState) {
  489. if (gestureState.numberActiveTouches === 2) {
  490. // this is a zoom event
  491. if (
  492. this.initialDistance === undefined
  493. || isNaN(this.initialDistance)
  494. ) {
  495. // there is no initial distance because the user started
  496. // with only one finger. We calculate it now.
  497. this.initialDistance = this._getTouchDistance(evt);
  498. } else {
  499. const distance = this._getTouchDistance(evt);
  500. const scale = distance / (this.initialDistance || 1);
  501. this.initialDistance = distance;
  502. this._onGesture('scale', scale);
  503. }
  504. } else if (gestureState.numberActiveTouches === 1
  505. && isNaN(this.initialDistance)
  506. && this._didMove(gestureState)) {
  507. // this is a move event
  508. const position = this._getTouchPosition(evt);
  509. const move = {
  510. x: position.x - this.initialPosition.x,
  511. y: position.y - this.initialPosition.y
  512. };
  513. this.initialPosition = position;
  514. this._onGesture('move', move);
  515. }
  516. }
  517. _onPanResponderRelease: () => void
  518. /**
  519. * Handles the PanResponder gesture end event.
  520. *
  521. * @private
  522. * @returns {void}
  523. */
  524. _onPanResponderRelease() {
  525. if (this.lastTap && Date.now() - this.lastTap < TAP_TIMEOUT_MS) {
  526. this._onGesture('press');
  527. }
  528. delete this.initialDistance;
  529. delete this.initialPosition;
  530. }
  531. _onStartShouldSetPanResponder: () => boolean
  532. /**
  533. * Function to decide whether the responder should respond to a start
  534. * (thouch) event.
  535. *
  536. * @private
  537. * @returns {boolean}
  538. */
  539. _onStartShouldSetPanResponder() {
  540. return typeof this.props.onPress === 'function';
  541. }
  542. /**
  543. * Restores the last applied transform when the component is mounted, or
  544. * a new stream is about to be rendered.
  545. *
  546. * @param {string} streamId - The stream id to restore transform for.
  547. * @private
  548. * @returns {void}
  549. */
  550. _restoreTransform(streamId) {
  551. const { enabled, _transforms } = this.props;
  552. if (enabled) {
  553. const initialTransform = _transforms[streamId];
  554. if (initialTransform) {
  555. this.setState({
  556. transform: initialTransform
  557. });
  558. }
  559. }
  560. }
  561. /**
  562. * Stores/saves the current transform when the component is destroyed, or a
  563. * new stream is about to be rendered.
  564. *
  565. * @private
  566. * @returns {void}
  567. */
  568. _storeTransform() {
  569. const { _onUnmount, enabled, streamId } = this.props;
  570. if (enabled) {
  571. _onUnmount(streamId, this.state.transform);
  572. }
  573. }
  574. }
  575. /**
  576. * Maps dispatching of some action to React component props.
  577. *
  578. * @param {Function} dispatch - Redux action dispatcher.
  579. * @private
  580. * @returns {{
  581. * _onUnmount: Function
  582. * }}
  583. */
  584. function _mapDispatchToProps(dispatch) {
  585. return {
  586. /**
  587. * Dispatches actions to store the last applied transform to a video.
  588. *
  589. * @param {string} streamId - The ID of the stream.
  590. * @param {Transform} transform - The last applied transform.
  591. * @private
  592. * @returns {void}
  593. */
  594. _onUnmount(streamId, transform) {
  595. dispatch(storeVideoTransform(streamId, transform));
  596. }
  597. };
  598. }
  599. /**
  600. * Maps (parts of) the redux state to the component's props.
  601. *
  602. * @param {Object} state - The redux state.
  603. * @param {Object} ownProps - The component's own props.
  604. * @private
  605. * @returns {{
  606. * _transforms: Object
  607. * }}
  608. */
  609. function _mapStateToProps(state) {
  610. return {
  611. /**
  612. * The stored transforms retreived from Redux to be initially applied to
  613. * different streams.
  614. *
  615. * @private
  616. * @type {Object}
  617. */
  618. _transforms: state['features/base/media'].video.transforms
  619. };
  620. }
  621. export default connect(_mapStateToProps, _mapDispatchToProps)(VideoTransform);