diff --git a/react/features/base/media/actionTypes.js b/react/features/base/media/actionTypes.js
index 592644e7e..8b9866c50 100644
--- a/react/features/base/media/actionTypes.js
+++ b/react/features/base/media/actionTypes.js
@@ -49,6 +49,18 @@ export const SET_VIDEO_AVAILABLE = Symbol('SET_VIDEO_AVAILABLE');
*/
export const SET_VIDEO_MUTED = Symbol('SET_VIDEO_MUTED');
+/**
+ * The type of (redux) action to store the last video {@link Transform} applied
+ * to a stream.
+ *
+ * {
+ * type: STORE_VIDEO_TRANSFORM,
+ * streamId: string,
+ * transform: Transform
+ * }
+ */
+export const STORE_VIDEO_TRANSFORM = Symbol('STORE_VIDEO_TRANSFORM');
+
/**
* The type of (redux) action to toggle the local video camera facing mode. In
* contrast to SET_CAMERA_FACING_MODE, allows the toggling to be optimally
diff --git a/react/features/base/media/actions.js b/react/features/base/media/actions.js
index 79a203b19..7eab0977f 100644
--- a/react/features/base/media/actions.js
+++ b/react/features/base/media/actions.js
@@ -8,6 +8,7 @@ import {
SET_CAMERA_FACING_MODE,
SET_VIDEO_AVAILABLE,
SET_VIDEO_MUTED,
+ STORE_VIDEO_TRANSFORM,
TOGGLE_CAMERA_FACING_MODE
} from './actionTypes';
import { CAMERA_FACING_MODE, VIDEO_MUTISM_AUTHORITY } from './constants';
@@ -18,9 +19,9 @@ import { CAMERA_FACING_MODE, VIDEO_MUTISM_AUTHORITY } from './constants';
* @param {boolean} available - True if the local audio is to be marked as
* available or false if the local audio is not available.
* @returns {{
- * type: SET_AUDIO_AVAILABLE,
- * available: boolean
- * }}
+ * type: SET_AUDIO_AVAILABLE,
+ * available: boolean
+ * }}
*/
export function setAudioAvailable(available: boolean) {
return {
@@ -112,6 +113,26 @@ export function setVideoMuted(
};
}
+/**
+ * Creates an action to store the last video {@link Transform} applied to a
+ * stream.
+ *
+ * @param {string} streamId - The ID of the stream.
+ * @param {Object} transform - The {@code Transform} to store.
+ * @returns {{
+ * type: STORE_VIDEO_TRANSFORM,
+ * streamId: string,
+ * transform: Object
+ * }}
+ */
+export function storeVideoTransform(streamId: string, transform: Object) {
+ return {
+ type: STORE_VIDEO_TRANSFORM,
+ streamId,
+ transform
+ };
+}
+
/**
* Toggles the camera facing mode. Most commonly, for example, mobile devices
* such as phones have a front/user-facing and a back/environment-facing
diff --git a/react/features/base/media/components/AbstractVideoTrack.js b/react/features/base/media/components/AbstractVideoTrack.js
index 721a531f6..5a2321965 100644
--- a/react/features/base/media/components/AbstractVideoTrack.js
+++ b/react/features/base/media/components/AbstractVideoTrack.js
@@ -36,7 +36,12 @@ export default class AbstractVideoTrack extends Component {
* of all Videos. For more details, refer to the zOrder property of the
* Video class for React Native.
*/
- zOrder: PropTypes.number
+ zOrder: PropTypes.number,
+
+ /**
+ * Indicates whether zooming (pinch to zoom and/or drag) is enabled.
+ */
+ zoomEnabled: PropTypes.bool
};
/**
@@ -80,7 +85,7 @@ export default class AbstractVideoTrack extends Component {
* @returns {ReactElement}
*/
render() {
- const videoTrack = this.state.videoTrack;
+ const { videoTrack } = this.state;
let render;
if (this.props.waitForVideoStarted) {
@@ -108,13 +113,21 @@ export default class AbstractVideoTrack extends Component {
const stream
= render ? videoTrack.jitsiTrack.getOriginalStream() : null;
+ // Actual zoom is currently only enabled if the stream is a desktop
+ // stream.
+ const zoomEnabled
+ = this.props.zoomEnabled
+ && stream
+ && videoTrack.videoType === 'desktop';
+
return (
+ zOrder = { this.props.zOrder }
+ zoomEnabled = { zoomEnabled } />
);
}
@@ -125,7 +138,7 @@ export default class AbstractVideoTrack extends Component {
* @returns {void}
*/
_onVideoPlaying() {
- const videoTrack = this.props.videoTrack;
+ const { videoTrack } = this.props;
if (videoTrack && !videoTrack.videoStarted) {
this.props.dispatch(trackVideoStarted(videoTrack.jitsiTrack));
diff --git a/react/features/base/media/components/native/Video.js b/react/features/base/media/components/native/Video.js
index f3dd668cf..b1215cd50 100644
--- a/react/features/base/media/components/native/Video.js
+++ b/react/features/base/media/components/native/Video.js
@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { RTCView } from 'react-native-webrtc';
-import { Pressable } from '../../../react';
-
import styles from './styles';
+import VideoTransform from './VideoTransform';
/**
* The React Native {@link Component} which is similar to Web's
@@ -54,7 +53,12 @@ export default class Video extends Component<*> {
* values: 0 for the remote video(s) which appear in the background, and
* 1 for the local video(s) which appear above the remote video(s).
*/
- zOrder: PropTypes.number
+ zOrder: PropTypes.number,
+
+ /**
+ * Indicates whether zooming (pinch to zoom and/or drag) is enabled.
+ */
+ zoomEnabled: PropTypes.bool
};
/**
@@ -77,29 +81,29 @@ export default class Video extends Component<*> {
* @returns {ReactElement|null}
*/
render() {
- const { stream } = this.props;
+ const { stream, zoomEnabled } = this.props;
if (stream) {
const streamURL = stream.toURL();
-
- // XXX The CSS style object-fit that we utilize on Web is not
- // supported on React Native. Adding objectFit to React Native's
- // StyleSheet appears to be impossible without hacking and an
- // unjustified amount of effort. Consequently, I've chosen to define
- // objectFit on RTCView itself. Anyway, prepare to accommodate a
- // future definition of objectFit in React Native's StyleSheet.
const style = styles.video;
- const objectFit = (style && style.objectFit) || 'cover';
+ const objectFit
+ = zoomEnabled
+ ? 'contain'
+ : (style && style.objectFit) || 'cover';
return (
-
+
-
+
);
}
diff --git a/react/features/base/media/components/native/VideoTransform.js b/react/features/base/media/components/native/VideoTransform.js
new file mode 100644
index 000000000..6e1cdff8c
--- /dev/null
+++ b/react/features/base/media/components/native/VideoTransform.js
@@ -0,0 +1,714 @@
+// @flow
+
+import React, { Component } from 'react';
+import { PanResponder, View } from 'react-native';
+import { connect } from 'react-redux';
+
+import { storeVideoTransform } from '../../actions';
+import styles from './styles';
+
+/**
+ * The default/initial transform (= no transform).
+ */
+const DEFAULT_TRANSFORM = {
+ scale: 1,
+ translateX: 0,
+ translateY: 0
+};
+
+/**
+ * The minimum scale (magnification) multiplier. 1 is equal to objectFit
+ * = 'contain'.
+ */
+const MIN_SCALE = 1;
+
+/*
+ * The max distance from the edge of the screen where we let the user move the
+ * view to. This is large enough now to let the user drag the view to a position
+ * where no other displayed components cover it (such as filmstrip). If a
+ * ViewPort (hint) support is added to the LargeVideo component then this
+ * contant will not be necessary anymore.
+ */
+const MAX_OFFSET = 100;
+
+/**
+ * The max allowed scale (magnification) multiplier.
+ */
+const MAX_SCALE = 5;
+
+/**
+ * The length of a minimum movement after which we consider a gesture a move
+ * instead of a tap/long tap.
+ */
+const MOVE_THRESHOLD_DISMISSES_TOUCH = 2;
+
+/**
+ * A tap timeout after which we consider a gesture a long tap and will not
+ * trigger onPress (unless long tap gesture support is added in the future).
+ */
+const TAP_TIMEOUT_MS = 400;
+
+/**
+ * Type of a transform object this component is capable of handling.
+ */
+type Transform = {
+ scale: number,
+ translateX: number,
+ translateY: number
+};
+
+type Props = {
+
+ /**
+ * The children components of this view.
+ */
+ children: Object,
+
+ /**
+ * Transformation is only enabled when this flag is true.
+ */
+ enabled: boolean,
+
+ /**
+ * Function to invoke when a press event is detected.
+ */
+ onPress?: Function,
+
+ /**
+ * The id of the current stream that is displayed.
+ */
+ streamId: string,
+
+ /**
+ * Style of the top level transformable view.
+ */
+ style: Object,
+
+ /**
+ * The stored transforms retreived from Redux to be initially applied
+ * to different streams.
+ */
+ _transforms: Object,
+
+ /**
+ * Action to dispatch when the component is unmounted.
+ */
+ _onUnmount: Function
+};
+
+type State = {
+
+ /**
+ * The current (non-transformed) layout of the View.
+ */
+ layout: ?Object,
+
+ /**
+ * The current transform that is applied.
+ */
+ transform: Transform
+};
+
+/**
+ * An container that captures gestures such as pinch&zoom, touch or move.
+ */
+class VideoTransform extends Component {
+ /**
+ * The gesture handler object.
+ */
+ gestureHandlers: PanResponder;
+
+ /**
+ * The initial distance of the fingers on pinch start.
+ */
+ initialDistance: number;
+
+ /**
+ * The initial position of the finger on touch start.
+ */
+ initialPosition: {
+ x: number,
+ y: number
+ };
+
+ /**
+ * Time of the last tap.
+ */
+ lastTap: number;
+
+ /**
+ * Constructor of the component.
+ *
+ * @inheritdoc
+ */
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ layout: null,
+ transform: DEFAULT_TRANSFORM
+ };
+
+ this._getTransformStyle = this._getTransformStyle.bind(this);
+ this._onGesture = this._onGesture.bind(this);
+ this._onLayout = this._onLayout.bind(this);
+ this._onMoveShouldSetPanResponder
+ = this._onMoveShouldSetPanResponder.bind(this);
+ this._onPanResponderGrant = this._onPanResponderGrant.bind(this);
+ this._onPanResponderMove = this._onPanResponderMove.bind(this);
+ this._onPanResponderRelease = this._onPanResponderRelease.bind(this);
+ this._onStartShouldSetPanResponder
+ = this._onStartShouldSetPanResponder.bind(this);
+ }
+
+ /**
+ * Implements React Component's componentWillMount.
+ *
+ * @inheritdoc
+ */
+ componentWillMount() {
+ this.gestureHandlers = PanResponder.create({
+ onPanResponderGrant: this._onPanResponderGrant,
+ onPanResponderMove: this._onPanResponderMove,
+ onPanResponderRelease: this._onPanResponderRelease,
+ onPanResponderTerminationRequest: () => true,
+ onMoveShouldSetPanResponder: this._onMoveShouldSetPanResponder,
+ onShouldBlockNativeResponder: () => false,
+ onStartShouldSetPanResponder: this._onStartShouldSetPanResponder
+ });
+
+ const { streamId } = this.props;
+
+ this._restoreTransform(streamId);
+ }
+
+ /**
+ * Implements React Component's componentWillReceiveProps.
+ *
+ * @inheritdoc
+ */
+ componentWillReceiveProps({ streamId: newStreamId }) {
+ if (this.props.streamId !== newStreamId) {
+ this._storeTransform();
+ this._restoreTransform(newStreamId);
+ }
+ }
+
+ /**
+ * Implements React Component's componentWillUnmount.
+ *
+ * @inheritdoc
+ */
+ componentWillUnmount() {
+ this._storeTransform();
+ }
+
+ /**
+ * Renders the empty component that captures the gestures.
+ *
+ * @inheritdoc
+ */
+ render() {
+ const { children, style } = this.props;
+
+ return (
+
+
+ { children }
+
+
+ );
+ }
+
+ /**
+ * Calculates the new transformation to be applied by merging the current
+ * transform values with the newly received incremental values.
+ *
+ * @param {Transform} transform - The new transform object.
+ * @private
+ * @returns {Transform}
+ */
+ _calculateTransformIncrement(transform: Transform) {
+ let {
+ scale,
+ translateX,
+ translateY
+ } = this.state.transform;
+ const {
+ scale: newScale,
+ translateX: newTranslateX,
+ translateY: newTranslateY
+ } = transform;
+
+ // Note: We don't limit MIN_SCALE here yet, as we need to detect a scale
+ // down gesture even if the scale is already at MIN_SCALE to let the
+ // user return the screen to center with that gesture. Scale is limited
+ // to MIN_SCALE right before it gets applied.
+ scale = Math.min(scale * (newScale || 1), MAX_SCALE);
+
+ translateX = translateX + ((newTranslateX || 0) / scale);
+ translateY = translateY + ((newTranslateY || 0) / scale);
+
+ return {
+ scale,
+ translateX,
+ translateY
+ };
+ }
+
+ _didMove: Object => boolean
+
+ /**
+ * Determines if there was large enough movement to be handled.
+ *
+ * @param {Object} gestureState - The gesture state.
+ * @returns {boolean}
+ */
+ _didMove({ dx, dy }) {
+ return Math.abs(dx) > MOVE_THRESHOLD_DISMISSES_TOUCH
+ || Math.abs(dy) > MOVE_THRESHOLD_DISMISSES_TOUCH;
+ }
+
+ _getTouchDistance: Object => number;
+
+ /**
+ * Calculates the touch distance on a pinch event.
+ *
+ * @param {Object} evt - The touch event.
+ * @private
+ * @returns {number}
+ */
+ _getTouchDistance({ nativeEvent: { touches } }) {
+ const dx = Math.abs(touches[0].pageX - touches[1].pageX);
+ const dy = Math.abs(touches[0].pageY - touches[1].pageY);
+
+ return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
+ }
+
+ _getTouchPosition: Object => Object
+
+ /**
+ * Calculates the position of the touch event.
+ *
+ * @param {Object} evt - The touch event.
+ * @private
+ * @returns {Object}
+ */
+ _getTouchPosition({ nativeEvent: { touches } }) {
+ return {
+ x: touches[0].pageX,
+ y: touches[0].pageY
+ };
+ }
+
+ _getTransformStyle: () => Object
+
+ /**
+ * Generates a transform style object to be used on the component.
+ *
+ * @returns {{string: Array<{string: number}>}}
+ */
+ _getTransformStyle() {
+ const { enabled } = this.props;
+
+ if (!enabled) {
+ return null;
+ }
+
+ const {
+ scale,
+ translateX,
+ translateY
+ } = this.state.transform;
+
+ return {
+ transform: [
+ { scale },
+ { translateX },
+ { translateY }
+ ]
+ };
+ }
+
+ /**
+ * Limits the move matrix and then applies the transformation to the
+ * component (updates state).
+ *
+ * Note: Points A (top-left) and D (bottom-right) are opposite points of
+ * the View rectangle.
+ *
+ * @param {Transform} transform - The transformation object.
+ * @private
+ * @returns {void}
+ */
+ _limitAndApplyTransformation(transform: Transform) {
+ const { layout } = this.state;
+
+ if (layout) {
+ const { scale } = this.state.transform;
+ const { scale: newScaleUnlimited } = transform;
+ let {
+ translateX: newTranslateX,
+ translateY: newTranslateY
+ } = transform;
+
+ // Scale is only limited to MIN_SCALE here to detect downscale
+ // gesture later.
+ const newScale = Math.max(newScaleUnlimited, MIN_SCALE);
+
+ // The A and D points of the original View (before transform).
+ const originalLayout = {
+ a: {
+ x: layout.x,
+ y: layout.y
+ },
+ d: {
+ x: layout.x + layout.width,
+ y: layout.y + layout.height
+ }
+ };
+
+ // The center point (midpoint) of the transformed View.
+ const transformedCenterPoint = {
+ x: ((layout.x + layout.width) / 2) + (newTranslateX * newScale),
+ y: ((layout.y + layout.height) / 2) + (newTranslateY * newScale)
+ };
+
+ // The size of the transformed View.
+ const transformedSize = {
+ height: layout.height * newScale,
+ width: layout.width * newScale
+ };
+
+ // The A and D points of the transformed View.
+ const transformedLayout = {
+ a: {
+ x: transformedCenterPoint.x - (transformedSize.width / 2),
+ y: transformedCenterPoint.y - (transformedSize.height / 2)
+ },
+ d: {
+ x: transformedCenterPoint.x + (transformedSize.width / 2),
+ y: transformedCenterPoint.y + (transformedSize.height / 2)
+ }
+ };
+
+ let _MAX_OFFSET = MAX_OFFSET;
+
+ if (newScaleUnlimited < scale) {
+ // This is a negative scale event so we dynamycally reduce the
+ // MAX_OFFSET to get the screen back to the center on
+ // downscaling.
+ _MAX_OFFSET = Math.min(MAX_OFFSET, MAX_OFFSET * (newScale - 1));
+ }
+
+ // Correct move matrix if it goes out of the view
+ // too much (_MAX_OFFSET).
+ newTranslateX
+ -= Math.max(
+ transformedLayout.a.x - originalLayout.a.x - _MAX_OFFSET,
+ 0);
+ newTranslateX
+ += Math.max(
+ originalLayout.d.x - transformedLayout.d.x - _MAX_OFFSET,
+ 0);
+ newTranslateY
+ -= Math.max(
+ transformedLayout.a.y - originalLayout.a.y - _MAX_OFFSET,
+ 0);
+ newTranslateY
+ += Math.max(
+ originalLayout.d.y - transformedLayout.d.y - _MAX_OFFSET,
+ 0);
+
+ this.setState({
+ transform: {
+ scale: newScale,
+ translateX: Math.round(newTranslateX),
+ translateY: Math.round(newTranslateY)
+ }
+ });
+ }
+ }
+
+ _onGesture: (string, ?Object | number) => void
+
+ /**
+ * Handles gestures and converts them to transforms.
+ *
+ * Currently supported gestures:
+ * - scale (punch&zoom-type scale).
+ * - move
+ * - press.
+ *
+ * Note: This component supports onPress solely to overcome the problem of
+ * not being able to register gestures via the PanResponder due to the fact
+ * that the entire Conference component was a single touch responder
+ * component in the past (see base/react/.../Container with an onPress
+ * event) - and stock touch responder components seem to have exclusive
+ * priority in handling touches in React.
+ *
+ * @param {string} type - The type of the gesture.
+ * @param {?Object | number} value - The value of the gesture, if any.
+ * @returns {void}
+ */
+ _onGesture(type, value) {
+ let transform;
+
+ switch (type) {
+ case 'move':
+ transform = {
+ ...DEFAULT_TRANSFORM,
+ translateX: value.x,
+ translateY: value.y
+ };
+ break;
+ case 'scale':
+ transform = {
+ ...DEFAULT_TRANSFORM,
+ scale: value
+ };
+ break;
+
+ case 'press': {
+ const { onPress } = this.props;
+
+ typeof onPress === 'function' && onPress();
+ break;
+ }
+ }
+
+ if (transform) {
+ this._limitAndApplyTransformation(
+ this._calculateTransformIncrement(transform));
+ }
+
+ this.lastTap = 0;
+ }
+
+ _onLayout: Object => void
+
+ /**
+ * Callback for the onLayout of the component.
+ *
+ * @param {Object} event - The native props of the onLayout event.
+ * @private
+ * @returns {void}
+ */
+ _onLayout({ nativeEvent: { layout: { x, y, width, height } } }) {
+ this.setState({
+ layout: {
+ x,
+ y,
+ width,
+ height
+ }
+ });
+ }
+
+ _onMoveShouldSetPanResponder: (Object, Object) => boolean
+
+ /**
+ * Function to decide whether the responder should respond to a move event.
+ *
+ * @param {Object} evt - The event.
+ * @param {Object} gestureState - Gesture state.
+ * @private
+ * @returns {boolean}
+ */
+ _onMoveShouldSetPanResponder(evt, gestureState) {
+ return this.props.enabled
+ && (this._didMove(gestureState)
+ || gestureState.numberActiveTouches === 2);
+ }
+
+ _onPanResponderGrant: (Object, Object) => void
+
+ /**
+ * Calculates the initial touch distance.
+ *
+ * @param {Object} evt - Touch event.
+ * @param {Object} gestureState - Gesture state.
+ * @private
+ * @returns {void}
+ */
+ _onPanResponderGrant(evt, { numberActiveTouches }) {
+ if (numberActiveTouches === 1) {
+ this.initialPosition = this._getTouchPosition(evt);
+ this.lastTap = Date.now();
+
+ } else if (numberActiveTouches === 2) {
+ this.initialDistance = this._getTouchDistance(evt);
+ }
+ }
+
+ _onPanResponderMove: (Object, Object) => void
+
+ /**
+ * Handles the PanResponder move (touch move) event.
+ *
+ * @param {Object} evt - Touch event.
+ * @param {Object} gestureState - Gesture state.
+ * @private
+ * @returns {void}
+ */
+ _onPanResponderMove(evt, gestureState) {
+ if (gestureState.numberActiveTouches === 2) {
+ // this is a zoom event
+ if (
+ this.initialDistance === undefined
+ || isNaN(this.initialDistance)
+ ) {
+ // there is no initial distance because the user started
+ // with only one finger. We calculate it now.
+ this.initialDistance = this._getTouchDistance(evt);
+ } else {
+ const distance = this._getTouchDistance(evt);
+ const scale = distance / (this.initialDistance || 1);
+
+ this.initialDistance = distance;
+
+ this._onGesture('scale', scale);
+ }
+ } else if (gestureState.numberActiveTouches === 1
+ && isNaN(this.initialDistance)
+ && this._didMove(gestureState)) {
+ // this is a move event
+ const position = this._getTouchPosition(evt);
+ const move = {
+ x: position.x - this.initialPosition.x,
+ y: position.y - this.initialPosition.y
+ };
+
+ this.initialPosition = position;
+
+ this._onGesture('move', move);
+ }
+ }
+
+ _onPanResponderRelease: () => void
+
+ /**
+ * Handles the PanResponder gesture end event.
+ *
+ * @private
+ * @returns {void}
+ */
+ _onPanResponderRelease() {
+ if (this.lastTap && Date.now() - this.lastTap < TAP_TIMEOUT_MS) {
+ this._onGesture('press');
+ }
+ delete this.initialDistance;
+ delete this.initialPosition;
+ }
+
+ _onStartShouldSetPanResponder: () => boolean
+
+ /**
+ * Function to decide whether the responder should respond to a start
+ * (thouch) event.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _onStartShouldSetPanResponder() {
+ return typeof this.props.onPress === 'function';
+ }
+
+ /**
+ * Restores the last applied transform when the component is mounted, or
+ * a new stream is about to be rendered.
+ *
+ * @param {string} streamId - The stream id to restore transform for.
+ * @private
+ * @returns {void}
+ */
+ _restoreTransform(streamId) {
+ const { enabled, _transforms } = this.props;
+
+ if (enabled) {
+ const initialTransform = _transforms[streamId];
+
+ if (initialTransform) {
+ this.setState({
+ transform: initialTransform
+ });
+ }
+ }
+ }
+
+ /**
+ * Stores/saves the current transform when the component is destroyed, or a
+ * new stream is about to be rendered.
+ *
+ * @private
+ * @returns {void}
+ */
+ _storeTransform() {
+ const { _onUnmount, enabled, streamId } = this.props;
+
+ if (enabled) {
+ _onUnmount(streamId, this.state.transform);
+ }
+ }
+}
+
+/**
+ * Maps dispatching of some action to React component props.
+ *
+ * @param {Function} dispatch - Redux action dispatcher.
+ * @private
+ * @returns {{
+ * _onUnmount: Function
+ * }}
+ */
+function _mapDispatchToProps(dispatch) {
+ return {
+ /**
+ * Dispatches actions to store the last applied transform to a video.
+ *
+ * @param {string} streamId - The ID of the stream.
+ * @param {Transform} transform - The last applied transform.
+ * @private
+ * @returns {void}
+ */
+ _onUnmount(streamId, transform) {
+ dispatch(storeVideoTransform(streamId, transform));
+ }
+ };
+}
+
+/**
+ * Maps (parts of) the redux state to the component's props.
+ *
+ * @param {Object} state - The redux state.
+ * @param {Object} ownProps - The component's own props.
+ * @private
+ * @returns {{
+ * _transforms: Object
+ * }}
+ */
+function _mapStateToProps(state) {
+ return {
+ /**
+ * The stored transforms retreived from Redux to be initially applied to
+ * different streams.
+ *
+ * @private
+ * @type {Object}
+ */
+ _transforms: state['features/base/media'].video.transforms
+ };
+}
+
+export default connect(_mapStateToProps, _mapDispatchToProps)(VideoTransform);
diff --git a/react/features/base/media/components/native/styles.js b/react/features/base/media/components/native/styles.js
index c105fa069..fcf6fc83b 100644
--- a/react/features/base/media/components/native/styles.js
+++ b/react/features/base/media/components/native/styles.js
@@ -6,6 +6,23 @@ import { ColorPalette } from '../../../styles';
* The styles of the feature base/media.
*/
export default StyleSheet.create({
+
+ /**
+ * Base style of the transformed video view.
+ */
+ videoTranformedView: {
+ flex: 1
+ },
+
+ /**
+ * A basic style to avoid rendering a transformed view off the component,
+ * that can be visible on special occasions, such as during device rotate
+ * animation, or PiP mode.
+ */
+ videoTransformedViewContaier: {
+ overflow: 'hidden'
+ },
+
/**
* Make {@code Video} fill its container.
*/
diff --git a/react/features/base/media/reducer.js b/react/features/base/media/reducer.js
index b3dc17563..5157ebc87 100644
--- a/react/features/base/media/reducer.js
+++ b/react/features/base/media/reducer.js
@@ -1,6 +1,8 @@
import { combineReducers } from 'redux';
+import { CONFERENCE_FAILED, CONFERENCE_LEFT } from '../conference';
import { ReducerRegistry } from '../redux';
+import { TRACK_REMOVED } from '../tracks';
import {
SET_AUDIO_AVAILABLE,
@@ -8,6 +10,7 @@ import {
SET_CAMERA_FACING_MODE,
SET_VIDEO_AVAILABLE,
SET_VIDEO_MUTED,
+ STORE_VIDEO_TRANSFORM,
TOGGLE_CAMERA_FACING_MODE
} from './actionTypes';
import { CAMERA_FACING_MODE } from './constants';
@@ -73,7 +76,13 @@ function _audio(state = AUDIO_INITIAL_MEDIA_STATE, action) {
const VIDEO_INITIAL_MEDIA_STATE = {
available: true,
facingMode: CAMERA_FACING_MODE.USER,
- muted: 0
+ muted: 0,
+
+ /**
+ * The video {@link Transform}s applied to {@code MediaStream}s by
+ * {@code id} i.e. "pinch to zoom".
+ */
+ transforms: {}
};
/**
@@ -87,6 +96,10 @@ const VIDEO_INITIAL_MEDIA_STATE = {
*/
function _video(state = VIDEO_INITIAL_MEDIA_STATE, action) {
switch (action.type) {
+ case CONFERENCE_FAILED:
+ case CONFERENCE_LEFT:
+ return _clearAllVideoTransforms(state);
+
case SET_CAMERA_FACING_MODE:
return {
...state,
@@ -105,6 +118,9 @@ function _video(state = VIDEO_INITIAL_MEDIA_STATE, action) {
muted: action.muted
};
+ case STORE_VIDEO_TRANSFORM:
+ return _storeVideoTransform(state, action);
+
case TOGGLE_CAMERA_FACING_MODE: {
let cameraFacingMode = state.facingMode;
@@ -119,6 +135,9 @@ function _video(state = VIDEO_INITIAL_MEDIA_STATE, action) {
};
}
+ case TRACK_REMOVED:
+ return _trackRemoved(state, action);
+
default:
return state;
}
@@ -138,3 +157,65 @@ ReducerRegistry.register('features/base/media', combineReducers({
audio: _audio,
video: _video
}));
+
+/**
+ * Removes all stored video {@link Transform}s.
+ *
+ * @param {Object} state - The {@code video} state of the feature base/media.
+ * @private
+ * @returns {Object}
+ */
+function _clearAllVideoTransforms(state) {
+ return {
+ ...state,
+ transforms: VIDEO_INITIAL_MEDIA_STATE.transforms
+ };
+}
+
+/**
+ * Stores the last applied transform to a stream.
+ *
+ * @param {Object} state - The {@code video} state of the feature base/media.
+ * @param {Object} action - The redux action {@link STORE_VIDEO_TRANSFORM}.
+ * @private
+ * @returns {Object}
+ */
+function _storeVideoTransform(state, { streamId, transform }) {
+ return {
+ ...state,
+ transforms: {
+ ...state.transforms,
+ [streamId]: transform
+ }
+ };
+}
+
+/**
+ * Removes the stored video {@link Transform} associated with a
+ * {@code MediaStream} when its respective track is removed.
+ *
+ * @param {Object} state - The {@code video} state of the feature base/media.
+ * @param {Object} action - The redux action {@link TRACK_REMOVED}.
+ * @private
+ * @returns {Object}
+ */
+function _trackRemoved(state, { track: { jitsiTrack } }) {
+ if (jitsiTrack) {
+ const streamId = jitsiTrack.getStreamId();
+
+ if (streamId && streamId in state.transforms) {
+ const nextTransforms = {
+ ...state.transforms
+ };
+
+ delete nextTransforms[streamId];
+
+ return {
+ ...state,
+ transforms: nextTransforms
+ };
+ }
+ }
+
+ return state;
+}
diff --git a/react/features/base/participants/components/ParticipantView.native.js b/react/features/base/participants/components/ParticipantView.native.js
index a1ef61f7b..0c9d12420 100644
--- a/react/features/base/participants/components/ParticipantView.native.js
+++ b/react/features/base/participants/components/ParticipantView.native.js
@@ -117,7 +117,12 @@ type Props = {
* stacking space of all {@code Video}s. For more details, refer to the
* {@code zOrder} property of the {@code Video} class for React Native.
*/
- zOrder: number
+ zOrder: number,
+
+ /**
+ * Indicates whether zooming (pinch to zoom and/or drag) is enabled.
+ */
+ zoomEnabled: boolean
};
/**
@@ -127,6 +132,7 @@ type Props = {
* @extends Component
*/
class ParticipantView extends Component {
+
/**
* Renders the connection status label, if appropriate.
*
@@ -235,7 +241,8 @@ class ParticipantView extends Component {
onPress = { renderVideo ? onPress : undefined }
videoTrack = { videoTrack }
waitForVideoStarted = { waitForVideoStarted }
- zOrder = { this.props.zOrder } /> }
+ zOrder = { this.props.zOrder }
+ zoomEnabled = { this.props.zoomEnabled } /> }
{ renderAvatar
&& {
participantId = { _participantId }
style = { styles.largeVideo }
useConnectivityInfoLabel = { useConnectivityInfoLabel }
- zOrder = { 0 } />
+ zOrder = { 0 }
+ zoomEnabled = { true } />
);
}