From 90f38561c30845443d0a80c54aed43c4b9b3c8aa Mon Sep 17 00:00:00 2001 From: Maxim Khlobystov <maxim.khlobystov@gmail.com> Date: Tue, 6 Nov 2018 18:10:56 -0500 Subject: [PATCH] Add Webcam Settings to choose a camera --- .../ui/components/video-preview/component.jsx | 225 ++++++++++++++++++ .../ui/components/video-preview/container.jsx | 20 ++ .../ui/components/video-preview/service.js | 6 + .../ui/components/video-preview/styles.scss | 78 ++++++ .../components/video-provider/component.jsx | 7 +- .../video-provider/video-menu/container.jsx | 11 +- bigbluebutton-html5/private/locales/en.json | 8 + 7 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 bigbluebutton-html5/imports/ui/components/video-preview/component.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-preview/container.jsx create mode 100644 bigbluebutton-html5/imports/ui/components/video-preview/service.js create mode 100644 bigbluebutton-html5/imports/ui/components/video-preview/styles.scss diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx new file mode 100644 index 0000000000..dadb825dc7 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx @@ -0,0 +1,225 @@ +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { defineMessages, injectIntl, intlShape } from 'react-intl'; +import Button from '/imports/ui/components/button/component'; +import ModalBase from '/imports/ui/components/modal/base/component'; +import { styles } from './styles'; + +const propTypes = { + intl: intlShape.isRequired, + closeModal: PropTypes.func.isRequired, + startSharing: PropTypes.func.isRequired, + changeWebcam: PropTypes.func.isRequired, +}; + +const defaultProps = { + webcamDeviceId: null, +}; + +const intlMessages = defineMessages({ + webcamSettingsTitle: { + id: 'app.videoPreview.webcamSettingsTitle', + description: 'Title for the video preview modal', + }, + closeLabel: { + id: 'app.videoPreview.closeLabel', + description: 'Close button label', + }, + webcamPreviewLabel: { + id: 'app.videoPreview.webcamPreviewLabel', + description: 'Webcam preview label', + }, + cameraLabel: { + id: 'app.videoPreview.cameraLabel', + description: 'Camera dropdown label', + }, + cancelLabel: { + id: 'app.videoPreview.cancelLabel', + description: 'Cancel button label', + }, + startSharingLabel: { + id: 'app.videoPreview.startSharingLabel', + description: 'Start Sharing button label', + }, + webcamOptionLabel: { + id: 'app.videoPreview.webcamOptionLabel', + description: 'Default webcam option label', + }, + webcamNotFoundLabel: { + id: 'app.videoPreview.webcamNotFoundLabel', + description: 'Webcam not found label', + }, +}); + +class VideoPreview extends Component { + constructor(props) { + super(props); + + this.state = { + webcamDeviceId: null, + availableWebcams: null, + isStartSharingDisabled: false, + }; + + const { + intl, + closeModal, + startSharing, + changeWebcam, + } = props; + + this.handleJoinVideo = this.handleJoinVideo.bind(this); + this.closeModal = closeModal; + this.startSharing = startSharing; + this.changeWebcam = changeWebcam; + } + + handleSelectWebcam(event) { + const webcamValue = event.target.value; + this.setState({ webcamDeviceId: webcamValue }); + this.changeWebcam(webcamValue); + const constraints = { + video: { + deviceId: webcamValue ? { exact: webcamValue } : undefined, + width: { + max: 240, + }, + height: { + max: 180, + }, + }, + }; + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + this.video.srcObject = stream; + }); + } + + componentDidMount() { + const constraints = { + video: { + width: { + max: 240, + }, + height: { + max: 180, + }, + }, + }; + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + this.video.srcObject = stream; + navigator.mediaDevices.enumerateDevices().then((devices) => { + let isInitialDeviceSet = false; + const webcams = []; + devices.forEach((device) => { + if (device.kind == 'videoinput') { + webcams.push(device); + if (!isInitialDeviceSet) { + this.changeWebcam(device.deviceId); + this.setState({ webcamDeviceId: device.deviceId }); + isInitialDeviceSet = true; + } + } + }); + if (webcams.length > 0) { + this.setState({ availableWebcams: webcams }); + } + }); + }).catch(() => { + this.setState({ isStartSharingDisabled: true }); + }); + } + + handleJoinVideo() { + const { + joinVideo, + } = this.props; + + joinVideo(); + } + + render() { + const { + intl, + } = this.props; + + return ( + <span> + <ModalBase + overlayClassName={styles.overlay} + className={styles.modal} + onRequestClose={this.closeModal} + > + <header + className={styles.header} + > + <Button + className={styles.closeBtn} + label={intl.formatMessage(intlMessages.closeLabel)} + icon="close" + size="md" + hideLabel + onClick={this.closeModal} + /> + </header> + <h3 className={styles.title}> + {intl.formatMessage(intlMessages.webcamSettingsTitle)} + </h3> + <div className={styles.content}> + <div className={styles.col}> + <video id="preview" className={styles.preview} ref={(ref) => { this.video = ref; }} autoPlay playsInline /> + </div> + <div className={styles.options}> + <label className={styles.label}> + {intl.formatMessage(intlMessages.cameraLabel)} + </label> + {this.state.availableWebcams && this.state.availableWebcams.length > 0 ? ( + <select + defaultValue={this.state.webcamDeviceId} + className={styles.select} + onChange={this.handleSelectWebcam.bind(this)} + > + <option disabled> + {intl.formatMessage(intlMessages.webcamOptionLabel)} + </option> + {this.state.availableWebcams.map((webcam, index) => ( + <option key={index} value={webcam.deviceId}> + {webcam.label} + </option> + ))} + </select> + ) : + <select + className={styles.select} + > + <option disabled> + {intl.formatMessage(intlMessages.webcamNotFoundLabel)} + </option> + </select>} + </div> + </div> + <div className={styles.footer}> + <div className={styles.actions}> + <Button + label={intl.formatMessage(intlMessages.cancelLabel)} + onClick={this.closeModal} + /> + <Button + color="primary" + label={intl.formatMessage(intlMessages.startSharingLabel)} + onClick={this.startSharing} + disabled={this.state.isStartSharingDisabled} + /> + </div> + </div> + </ModalBase> + </span> + ); + } +} + +VideoPreview.propTypes = propTypes; +VideoPreview.defaultProps = defaultProps; + +export default injectIntl(VideoPreview); + diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx new file mode 100644 index 0000000000..7406ad63ac --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-preview/container.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { withModalMounter } from '/imports/ui/components/modal/service'; +import { withTracker } from 'meteor/react-meteor-data'; +import Service from './service'; +import VideoPreview from './component'; +import VideoService from '../video-provider/service'; + +const VideoPreviewContainer = props => <VideoPreview {...props} />; + +export default withModalMounter(withTracker(({ mountModal }) => ({ + closeModal: () => { + mountModal(null); + }, + startSharing: () => { + mountModal(null); + VideoService.joinVideo(); + }, + changeWebcam: deviceId => Service.changeWebcam(deviceId), +}))(VideoPreviewContainer)); + diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/service.js b/bigbluebutton-html5/imports/ui/components/video-preview/service.js new file mode 100644 index 0000000000..58b050d183 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-preview/service.js @@ -0,0 +1,6 @@ +export default { + changeWebcam: (deviceId) => { + Session.set('WebcamDeviceId', deviceId); + }, +}; + diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss b/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss new file mode 100644 index 0000000000..a984eae63b --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss @@ -0,0 +1,78 @@ +@import "/imports/ui/stylesheets/variables/_all"; +@import '/imports/ui/stylesheets/mixins/focus'; +@import "/imports/ui/components/modal/simple/styles"; + +.actions { + margin-left: auto; +} + +.closeBtn { + i { + color: var(--color-gray-light); + } + margin-left: auto; +} + +.col { + display: flex; + + width: 30%; + height: 100%; + + margin-right: 1.5rem; +} + +.content { + display: flex; + flex: 3; +} + +.footer { + display: flex; +} + +.header { + display: flex; + border: none; +} + +.label { + font-size: 0.85rem; + font-weight: bold; + color: var(--color-gray-label); +} + +.modal { + padding: 1rem; + @extend .modal; +} + +.overlay { + @extend .overlay; +} + +.preview { + width: 100%; + height: 100%; +} + +.row { + display: flex; +} + +.select { + margin-top: 0.4rem; + width: 100%; + height: 1.6rem; + + color: var(--color-gray-label); + border-radius: 0.3rem; + + background-color: var(--color-white); +} + +.title { + font-size: 1.4rem; + text-align: center; +} + diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 7959f3ed2f..d8cfc67985 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -9,6 +9,9 @@ import logger from '/imports/startup/client/logger'; import VideoService from './service'; import VideoList from './video-list/component'; +import Storage from '/imports/ui/services/storage/session'; +import { Session } from 'meteor/session'; + const VIDEO_CONSTRAINTS = Meteor.settings.public.kurento.cameraConstraints; const intlClientErrors = defineMessages({ @@ -394,6 +397,9 @@ class VideoProvider extends Component { } catch (error) { this.logger('error', 'Video provider failed to fetch ice servers, using default'); } finally { + if (Session.get('WebcamDeviceId')) { + VIDEO_CONSTRAINTS.deviceId = { exact: Session.get('WebcamDeviceId') }; + } const options = { mediaConstraints: { audio: false, @@ -544,7 +550,6 @@ class VideoProvider extends Component { return (event) => { const connectionState = peer.peerConnection.iceConnectionState; if (connectionState === 'failed' || connectionState === 'closed') { - // prevent the same error from being detected multiple times peer.peerConnection.oniceconnectionstatechange = null; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-menu/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-menu/container.jsx index 2994d93fae..0a5a0f5403 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-menu/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-menu/container.jsx @@ -4,6 +4,10 @@ import { defineMessages, injectIntl } from 'react-intl'; import JoinVideoOptions from './component'; import VideoMenuService from './service'; +import { withModalMounter } from '/imports/ui/components/modal/service'; +import AudioModalContainer from '/imports/ui/components/audio/audio-modal/container'; +import VideoPreviewContainer from '/imports/ui/components/video-preview/container'; + const intlMessages = defineMessages({ joinVideo: { id: 'app.video.joinVideo', @@ -33,6 +37,7 @@ const JoinVideoOptionsContainer = (props) => { swapLayoutAllowed, baseName, intl, + mountModal, ...restProps } = props; const videoItems = [ @@ -49,7 +54,7 @@ const JoinVideoOptionsContainer = (props) => { description: intl.formatMessage(intlMessages[isSharingVideo ? 'leaveVideo' : 'joinVideo']), label: intl.formatMessage(intlMessages[isSharingVideo ? 'leaveVideo' : 'joinVideo']), disabled: isDisabled && !isSharingVideo, - click: isSharingVideo ? handleCloseVideo : handleJoinVideo, + click: isSharingVideo ? handleCloseVideo : () => { mountModal(<VideoPreviewContainer />); }, id: isSharingVideo ? 'leave-video-button' : 'join-video-button', }, ]; @@ -57,11 +62,11 @@ const JoinVideoOptionsContainer = (props) => { return <JoinVideoOptions {...{ videoItems, isSharingVideo, ...restProps }} />; }; -export default injectIntl(withTracker(() => ({ +export default withModalMounter(injectIntl(withTracker(() => ({ baseName: VideoMenuService.baseName, isSharingVideo: VideoMenuService.isSharingVideo(), isDisabled: VideoMenuService.isDisabled(), videoShareAllowed: VideoMenuService.videoShareAllowed(), toggleSwapLayout: VideoMenuService.toggleSwapLayout, swapLayoutAllowed: VideoMenuService.swapLayoutAllowed(), -}))(JoinVideoOptionsContainer)); +}))(JoinVideoOptionsContainer))); diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index f383798a81..6f19cc60a2 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -333,6 +333,14 @@ "app.shortcut-help.closePrivateChat": "Close Private Chat", "app.shortcut-help.openActions": "Open Actions Menu", "app.shortcut-help.openStatus": "Open Status Menu", + "app.videoPreview.cameraLabel": "Camera", + "app.videoPreview.cancelLabel": "Cancel", + "app.videoPreview.closeLabel": "Close", + "app.videoPreview.startSharingLabel": "Start Sharing", + "app.videoPreview.webcamOptionLabel": "Choose webcam", + "app.videoPreview.webcamPreviewLabel": "Webcam preview", + "app.videoPreview.webcamSettingsTitle": "Webcam Settings", + "app.videoPreview.webcamNotFoundLabel": "Webcam not found", "app.video.joinVideo": "Share Webcam", "app.video.leaveVideo": "Unshare Webcam", "app.video.iceCandidateError": "Error on adding ice candidate", -- GitLab