From aef0287605f8839bbd5235b1537073e5f60b22ed Mon Sep 17 00:00:00 2001 From: Mihai-Andrei Uscat <52234168+muscat1@users.noreply.github.com> Date: Wed, 17 Mar 2021 10:44:18 +0200 Subject: [PATCH] feat(ToggleCamera): Implement for web. --- .eslintignore | 1 + react/features/base/config/constants.js | 3 +- .../base/icons/svg/camera-refresh.svg | 3 + react/features/base/icons/svg/index.js | 1 + .../base/lib-jitsi-meet/functions.any.js | 7 +- react/features/base/tracks/actions.js | 40 +++++++++- .../components/web/ToggleCameraButton.js | 77 +++++++++++++++++++ .../toolbox/components/web/Toolbox.js | 7 +- 8 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 react/features/base/icons/svg/camera-refresh.svg create mode 100644 react/features/toolbox/components/web/ToggleCameraButton.js diff --git a/.eslintignore b/.eslintignore index 1e6b4b72b5..d19de9fbb0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ flow-typed/* libs/* resources/* react/features/stream-effects/virtual-background/vendor/* +load-test/* # ESLint will by default ignore its own configuration file. However, there does # not seem to be a reason why we will want to risk being inconsistent with our diff --git a/react/features/base/config/constants.js b/react/features/base/config/constants.js index 8f63ecb7ac..db004c17b7 100644 --- a/react/features/base/config/constants.js +++ b/react/features/base/config/constants.js @@ -18,5 +18,6 @@ export const TOOLBAR_BUTTONS = [ 'fodeviceselection', 'hangup', 'profile', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts', - 'tileview', 'select-background', 'download', 'help', 'mute-everyone', 'mute-video-everyone', 'security' + 'tileview', 'select-background', 'download', 'help', 'mute-everyone', 'mute-video-everyone', + 'security', 'toggle-camera' ]; diff --git a/react/features/base/icons/svg/camera-refresh.svg b/react/features/base/icons/svg/camera-refresh.svg new file mode 100644 index 0000000000..3ba72747f3 --- /dev/null +++ b/react/features/base/icons/svg/camera-refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index f5f84e4cdf..713997e542 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -20,6 +20,7 @@ export { default as IconCamera } from './camera.svg'; export { default as IconCameraDisabled } from './camera-disabled.svg'; export { default as IconCameraEmpty } from './camera-empty.svg'; export { default as IconCameraEmptyDisabled } from './camera-empty-disabled.svg'; +export { default as IconCameraRefresh } from './camera-refresh.svg'; export { default as IconCancelSelection } from './cancel.svg'; export { default as IconChat } from './chat.svg'; export { default as IconChatSend } from './send.svg'; diff --git a/react/features/base/lib-jitsi-meet/functions.any.js b/react/features/base/lib-jitsi-meet/functions.any.js index f86a7fd2a2..a337558a00 100644 --- a/react/features/base/lib-jitsi-meet/functions.any.js +++ b/react/features/base/lib-jitsi-meet/functions.any.js @@ -14,9 +14,11 @@ const JitsiConnectionErrors = JitsiMeetJS.errors.connection; * are "video" or "audio". * @param {string} deviceId - The id of the target media source. * @param {number} [timeout] - A timeout for the JitsiMeetJS.createLocalTracks function call. + * @param {Object} additionalOptions - Extra options to be passed to lib-jitsi-meet's {@code createLocalTracks}. + * * @returns {Promise} */ -export function createLocalTrack(type: string, deviceId: string, timeout: ?number) { +export function createLocalTrack(type: string, deviceId: string, timeout: ?number, additionalOptions: ?Object) { return ( JitsiMeetJS.createLocalTracks({ cameraDeviceId: deviceId, @@ -26,7 +28,8 @@ export function createLocalTrack(type: string, deviceId: string, timeout: ?numbe firefox_fake_device: window.config && window.config.firefox_fake_device, micDeviceId: deviceId, - timeout + timeout, + ...additionalOptions }) .then(([ jitsiLocalTrack ]) => jitsiLocalTrack)); } diff --git a/react/features/base/tracks/actions.js b/react/features/base/tracks/actions.js index 5be7d504b7..274630eabd 100644 --- a/react/features/base/tracks/actions.js +++ b/react/features/base/tracks/actions.js @@ -1,9 +1,11 @@ +/* global APP */ + import { createTrackMutedEvent, sendAnalytics } from '../../analytics'; import { showErrorNotification, showNotification } from '../../notifications'; -import { JitsiTrackErrors, JitsiTrackEvents } from '../lib-jitsi-meet'; +import { JitsiTrackErrors, JitsiTrackEvents, createLocalTrack } from '../lib-jitsi-meet'; import { CAMERA_FACING_MODE, MEDIA_TYPE, @@ -13,6 +15,7 @@ import { VIDEO_TYPE } from '../media'; import { getLocalParticipant } from '../participants'; +import { updateSettings } from '../settings'; import { SET_NO_SRC_DATA_NOTIFICATION_UID, @@ -717,3 +720,38 @@ export function updateLastTrackVideoMediaEvent(track, name) { name }; } + +/** + * Toggles the facingMode constraint on the video stream. + * + * @returns {Function} + */ +export function toggleCamera() { + return async (dispatch, getState) => { + const state = getState(); + const tracks = state['features/base/tracks']; + const localVideoTrack = getLocalVideoTrack(tracks).jitsiTrack; + const currentFacingMode = localVideoTrack.getCameraFacingMode(); + + /** + * FIXME: Ideally, we should be dispatching {@code replaceLocalTrack} here, + * but it seems to not trigger the re-rendering of the local video on Chrome; + * could be due to a plan B vs unified plan issue. Therefore, we use the legacy + * method defined in conference.js that manually takes care of updating the local + * video as well. + */ + await APP.conference.useVideoStream(null); + + const targetFacingMode = currentFacingMode === CAMERA_FACING_MODE.USER + ? CAMERA_FACING_MODE.ENVIRONMENT + : CAMERA_FACING_MODE.USER; + + // Update the flipX value so the environment facing camera is not flipped, before the new track is created. + dispatch(updateSettings({ localFlipX: targetFacingMode === CAMERA_FACING_MODE.USER })); + + const newVideoTrack = await createLocalTrack('video', null, null, { facingMode: targetFacingMode }); + + // FIXME: See above. + await APP.conference.useVideoStream(newVideoTrack); + }; +} diff --git a/react/features/toolbox/components/web/ToggleCameraButton.js b/react/features/toolbox/components/web/ToggleCameraButton.js new file mode 100644 index 0000000000..43c2b69bd7 --- /dev/null +++ b/react/features/toolbox/components/web/ToggleCameraButton.js @@ -0,0 +1,77 @@ +// @flow + +import { isMobileBrowser } from '../../../base/environment/utils'; +import { translate } from '../../../base/i18n'; +import { IconCameraRefresh } from '../../../base/icons'; +import { connect } from '../../../base/redux'; +import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; +import { isLocalCameraTrackMuted, toggleCamera } from '../../../base/tracks'; + +/** + * The type of the React {@code Component} props of {@link ToggleCameraButton}. + */ +type Props = AbstractButtonProps & { + + /** + * Whether the current conference is in audio only mode or not. + */ + _audioOnly: boolean, + + /** + * Whether video is currently muted or not. + */ + _videoMuted: boolean, + + /** + * The Redux dispatch function. + */ + dispatch: Function +}; + +/** + * An implementation of a button for toggling the camera facing mode. + */ +class ToggleCameraButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.toggleCamera'; + icon = IconCameraRefresh; + label = 'toolbar.toggleCamera'; + + /** + * Handles clicking/pressing the button. + * + * @returns {void} + */ + _handleClick() { + this.props.dispatch(toggleCamera()); + } + + /** + * Whether this button is disabled or not. + * + * @returns {boolean} + */ + _isDisabled() { + return this.props._audioOnly || this.props._videoMuted; + } +} + +/** + * Maps (parts of) the redux state to the associated props for the + * {@code ToggleCameraButton} component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +function mapStateToProps(state): Object { + const { enabled: audioOnly } = state['features/base/audio-only']; + const tracks = state['features/base/tracks']; + const { videoInput } = state['features/base/devices'].availableDevices; + + return { + _audioOnly: Boolean(audioOnly), + _videoMuted: isLocalCameraTrackMuted(tracks), + visible: isMobileBrowser() && videoInput.length > 1 + }; +} + +export default translate(connect(mapStateToProps)(ToggleCameraButton)); diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index 7aba3353ca..a5ae8eb415 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -83,6 +83,7 @@ import MuteEveryoneButton from '../MuteEveryoneButton'; import AudioSettingsButton from './AudioSettingsButton'; import OverflowMenuButton from './OverflowMenuButton'; import OverflowMenuProfileItem from './OverflowMenuProfileItem'; +import ToggleCameraButton from './ToggleCameraButton'; import ToolbarButton from './ToolbarButton'; import VideoSettingsButton from './VideoSettingsButton'; @@ -929,7 +930,7 @@ class Toolbox extends Component { } /** - * Returns true if the profile button is visible and false otherwise. + * Returns true if the embed meeting button is visible and false otherwise. * * @returns {boolean} */ @@ -967,6 +968,10 @@ class Toolbox extends Component { const group1 = [ ...additionalButtons, + this._shouldShowButton('toggle-camera') + && , this._shouldShowButton('videoquality') &&