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