Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

VideoTransform.tsx 21KB

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