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

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