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 20KB

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