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