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

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