diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx index 595b0814c99cae8cb0550846367573a801cc4784..7478c90b26c9e568ab5361d6eae68c2322850816 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-preview/component.jsx @@ -12,6 +12,12 @@ import { styles } from './styles'; const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles; +const VIEW_STATES = { + finding: 'finding', + found: 'found', + error: 'error', +}; + const propTypes = { intl: intlShape.isRequired, closeModal: PropTypes.func.isRequired, @@ -59,6 +65,10 @@ const intlMessages = defineMessages({ id: 'app.videoPreview.startSharingLabel', description: 'Start Sharing button label', }, + findingWebcamsLabel: { + id: 'app.videoPreview.findingWebcamsLabel', + description: 'Finding webcams label', + }, webcamOptionLabel: { id: 'app.videoPreview.webcamOptionLabel', description: 'Default webcam option label', @@ -106,6 +116,51 @@ const intlMessages = defineMessages({ }); class VideoPreview extends Component { + static handleGUMError(error) { + // logger.error(error); + // logger.error(error.id); + // logger.error(error.name); + // console.log(error); + // console.log(error.name) + // console.log(error.message) + + // let convertedError; + + /* switch (error.name) { + case 'SourceUnavailableError': + case 'NotReadableError': + // hardware failure with the device + // NotReadableError: Could not start video source + break; + case 'NotAllowedError': + // media was disallowed + // NotAllowedError: Permission denied + convertedError = intlMessages.NotAllowedError; + break; + case 'AbortError': + // generic error occured + // AbortError: Starting video failed (FF when there's a hardware failure) + break; + case 'NotFoundError': + // no webcam found + // NotFoundError: The object can not be found here. + // NotFoundError: Requested device not found + convertedError = intlMessages.NotFoundError; + break; + case 'SecurityError': + // user media support is disabled on the document + break; + case 'TypeError': + // issue with constraints or maybe Chrome with HTTP + break; + default: + // default error message handling + break; + } */ + + return `${error.name}: ${error.message}`; + } + constructor(props) { super(props); @@ -116,12 +171,8 @@ class VideoPreview extends Component { this.handleJoinVideo = this.handleJoinVideo.bind(this); this.handleProceed = this.handleProceed.bind(this); this.handleStartSharing = this.handleStartSharing.bind(this); - // this.startPreview = this.startPreview.bind(this); - this.scanProfiles = this.scanProfiles.bind(this); - this.doGUM = this.doGUM.bind(this); - this.displayPreview = this.displayPreview.bind(this); - this.supportWarning = this.supportWarning.bind(this); - this.renderModalContent = this.renderModalContent.bind(this); + this.handleSelectWebcam = this.handleSelectWebcam.bind(this); + this.handleSelectProfile = this.handleSelectProfile.bind(this); this.deviceStream = null; @@ -133,6 +184,7 @@ class VideoPreview extends Component { availableProfiles: {}, selectedProfile: null, isStartSharingDisabled: true, + viewState: VIEW_STATES.finding, }; } @@ -145,62 +197,97 @@ class VideoPreview extends Component { // skipped then we get devices with no labels if (hasMediaDevices) { try { - navigator.mediaDevices.getUserMedia({ audio: false, video: true }).then(() => { - if (!this._isMounted) return; - - navigator.mediaDevices.enumerateDevices().then(async (devices) => { - const webcams = []; - let initialDeviceId; - + navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: 'user' } }) + .then((stream) => { if (!this._isMounted) return; + this.deviceStream = stream; + // try and get the deviceId for the initial stream + let firstAllowedDeviceId; + if (stream.getVideoTracks) { + const videoTracks = stream.getVideoTracks(); + if (videoTracks.length > 0 && videoTracks[0].getSettings) { + const trackSettings = videoTracks[0].getSettings(); + firstAllowedDeviceId = trackSettings.deviceId; + } + } - // set webcam - devices.forEach((device) => { - if (device.kind === 'videoinput') { - webcams.push(device); - if (!initialDeviceId || (webcamDeviceId && webcamDeviceId === device.deviceId)) { - initialDeviceId = device.deviceId; + navigator.mediaDevices.enumerateDevices().then((devices) => { + const webcams = []; + let initialDeviceId; + + if (!this._isMounted) return; + + // set webcam + devices.forEach((device) => { + if (device.kind === 'videoinput') { + webcams.push(device); + if (!initialDeviceId + || (webcamDeviceId && webcamDeviceId === device.deviceId) + || device.deviceId === firstAllowedDeviceId) { + initialDeviceId = device.deviceId; + } } - } - }); + }); - logger.debug({ - logCode: 'video_preview_enumerate_devices', - extraInfo: { - devices, - webcams, - }, - }, `Enumerate devices came back. There are ${devices.length} devices and ${webcams.length} are video inputs`); + logger.debug({ + logCode: 'video_preview_enumerate_devices', + extraInfo: { + devices, + webcams, + }, + }, `Enumerate devices came back. There are ${devices.length} devices and ${webcams.length} are video inputs`); - if (initialDeviceId) { + if (initialDeviceId) { + this.setState({ + availableWebcams: webcams, + }); + this.displayInitialPreview(initialDeviceId); + } this.setState({ - availableWebcams: webcams, + viewState: VIEW_STATES.found, }); - - this.scanProfiles(initialDeviceId); - } + }).catch((error) => { + logger.warn({ + logCode: 'video_preview_enumerate_error', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, 'Error enumerating devices'); + this.setState({ + viewState: VIEW_STATES.error, + deviceError: VideoPreview.handleGUMError(error), + }); + }); }).catch((error) => { - // CHANGE THIS TO SOMETHING USEFUL logger.warn({ - logCode: 'video_preview_enumerate_error', + logCode: 'video_preview_initial_device_error', extraInfo: { - error, + errorName: error.name, + errorMessage: error.message, }, - }, 'Error enumerating devices'); - this.handleGUMError(error); + }, 'Error getting initial device'); + this.setState({ + viewState: VIEW_STATES.error, + deviceError: VideoPreview.handleGUMError(error), + }); }); - }); } catch (error) { - // CHANGE THIS TO SOMETHING USEFUL logger.warn({ logCode: 'video_preview_grabbing_error', extraInfo: { - error, + errorName: error.name, + errorMessage: error.message, }, }, 'Error grabbing initial video stream'); - this.handleGUMError(error); + this.setState({ + viewState: VIEW_STATES.error, + deviceError: VideoPreview.handleGUMError(error), + }); } + } else { + // TODO: Add an error message when media is globablly disabled } } @@ -231,7 +318,7 @@ class VideoPreview extends Component { handleSelectWebcam(event) { const webcamValue = event.target.value; - this.scanProfiles(webcamValue); + this.displayInitialPreview(webcamValue); } handleSelectProfile(event) { @@ -257,139 +344,22 @@ class VideoPreview extends Component { if (resolve) resolve(); } - scanProfiles(deviceId) { + displayInitialPreview(deviceId) { const { changeWebcam } = this.props; - - this.stopTracks(); - - this.setState({ webcamDeviceId: deviceId }); - changeWebcam(deviceId); - - const availableProfiles = []; - let currNum = 0; - let previousWidth = 0; - let previousHeight = 0; + const availableProfiles = CAMERA_PROFILES; this.setState({ + webcamDeviceId: deviceId, isStartSharingDisabled: true, + availableProfiles, }); + changeWebcam(deviceId); - // logger.debug('starting scan'); - - const scanningCleanup = () => { - this.video.onloadedmetadata = undefined; - - if (availableProfiles.length > 0) { - const defaultProfile = availableProfiles.find(profile => profile.default) - || availableProfiles[0]; - - // webcam might no longer exist or be available - logger.debug({ - logCode: 'video_preview_check_webcam', - extraInfo: { - camProfile: CAMERA_PROFILES[currNum], - currNum, - }, - }, 'Error with camera profile'); - - this.displayPreview(deviceId, defaultProfile); - } - - this.setState({ - availableProfiles, - }); - }; - - const nextProfile = () => { - // logger.debug('next profile'); - if (currNum < CAMERA_PROFILES.length) { - this.doGUM(deviceId, CAMERA_PROFILES[currNum]).then((stream) => { - if (!this._isMounted) return; - - logger.debug({ - logCode: 'video_preview_next_profile', - extraInfo: { - camProfile: CAMERA_PROFILES[currNum], - currNum, - }, - }, 'Display preview came back'); - - this.video.srcObject = stream; - this.deviceStream = stream; - }).catch((error) => { - if (!this._isMounted) return; - - logger.debug({ - logCode: 'video_preview_next_profile', - extraInfo: { - camProfile: CAMERA_PROFILES[currNum], - currNum, - error, - }, - }, 'Error with fetching profile, skipping to next'); - currNum += 1; - - nextProfile(); - }); - } else { - // do clean up and select the starting profile - scanningCleanup(); - } - }; - - const checkWebcamExists = () => { - // logger.debug('initial webcam check'); - // we call gUM with no constraints so we know if any stream is available - this.doGUM(deviceId, {}).then(() => { - if (!this._isMounted) return; - - // We don't need to do anything with the returned stream - nextProfile(); - }).catch(() => { - if (!this._isMounted) return; - - // webcam might no longer exist or be available - logger.debug(`Error with profile: ${CAMERA_PROFILES[currNum].name}`); - - scanningCleanup(); - }); - }; - - const getVideoDimensions = () => { - // logger.debug('loaded metadata'); - if (!this.video.videoWidth) { - // logger.debug('no video width yet'); - setTimeout(getVideoDimensions, 250); - } - - if (this.video.videoWidth !== previousWidth || this.video.videoHeight !== previousHeight) { - previousWidth = this.video.videoWidth; - previousHeight = this.video.videoHeight; - logger.debug({ - logCode: 'video_preview_found_profile', - extraInfo: { - camProfile: CAMERA_PROFILES[currNum], - currNum, - }, - }, 'Found profile'); - availableProfiles.push(CAMERA_PROFILES[currNum]); - } else { - logger.debug({ - logCode: 'video_preview_not_including_profile', - extraInfo: { - camProfile: CAMERA_PROFILES[currNum], - currNum, - }, - }, 'Not including profile'); - } - - currNum += 1; - nextProfile(); - }; - - this.video.onloadedmetadata = getVideoDimensions; - - checkWebcamExists(); + if (availableProfiles.length > 0) { + const defaultProfile = availableProfiles.find(profile => profile.default) + || availableProfiles[0]; + this.displayPreview(deviceId, defaultProfile); + } } doGUM(deviceId, profile) { @@ -400,7 +370,9 @@ class VideoPreview extends Component { constraints.video.deviceId = { exact: deviceId }; this.stopTracks(); - this.video.srcObject = null; + if (this.video) { + this.video.srcObject = null; + } this.deviceStream = null; return navigator.mediaDevices.getUserMedia(constraints); @@ -409,7 +381,11 @@ class VideoPreview extends Component { displayPreview(deviceId, profile) { const { changeProfile } = this.props; - this.setState({ selectedProfile: profile.id }); + this.setState({ + selectedProfile: profile.id, + isStartSharingDisabled: true, + previewError: undefined, + }); changeProfile(profile.id); this.doGUM(deviceId, profile).then((stream) => { @@ -424,9 +400,11 @@ class VideoPreview extends Component { logger.warn({ logCode: 'video_preview_do_gum_preview_error', extraInfo: { - error, + errorName: error.name, + errorMessage: error.message, }, }, 'Error displaying final selection.'); + this.setState({ previewError: VideoPreview.handleGUMError(error) }); }); } @@ -453,7 +431,7 @@ class VideoPreview extends Component { ); } - renderModalContent() { + renderDeviceSelectors() { const { intl, } = this.props; @@ -463,6 +441,127 @@ class VideoPreview extends Component { availableWebcams, availableProfiles, selectedProfile, + } = this.state; + + return ( + <div className={styles.col}> + <label className={styles.label} htmlFor="setCam"> + {intl.formatMessage(intlMessages.cameraLabel)} + </label> + { + availableWebcams && availableWebcams.length > 0 + ? ( + <select + id="setCam" + value={webcamDeviceId || ''} + className={styles.select} + onChange={this.handleSelectWebcam} + > + {availableWebcams.map(webcam => ( + <option key={webcam.deviceId} value={webcam.deviceId}> + {webcam.label} + </option> + ))} + </select> + ) + : ( + <span> + {intl.formatMessage(intlMessages.webcamNotFoundLabel)} + </span> + ) + } + <label className={styles.label} htmlFor="setQuality"> + {intl.formatMessage(intlMessages.qualityLabel)} + </label> + { + availableProfiles && availableProfiles.length > 0 + ? ( + <select + id="setQuality" + value={selectedProfile || ''} + className={styles.select} + onChange={this.handleSelectProfile} + > + {availableProfiles.map(profile => ( + <option key={profile.id} value={profile.id}> + {profile.name} + </option> + ))} + </select> + ) + : ( + <span> + {intl.formatMessage(intlMessages.profileNotFoundLabel)} + </span> + ) + } + </div> + ); + } + + renderContent() { + const { + intl, + } = this.props; + + const { + viewState, + deviceError, + previewError, + } = this.state; + + switch (viewState) { + case VIEW_STATES.finding: + return ( + <div className={styles.content}> + <div className={styles.videoCol}> + <div> + <span>{intl.formatMessage(intlMessages.findingWebcamsLabel)}</span> + <span className={styles.fetchingAnimation} /> + </div> + </div> + </div> + ); + case VIEW_STATES.error: + return ( + <div className={styles.content}> + <div className={styles.videoCol}><div>{deviceError}</div></div> + </div> + ); + case VIEW_STATES.found: + default: + return ( + <div className={styles.content}> + <div className={styles.videoCol}> + { + previewError + ? ( + <div>{previewError}</div> + ) + : ( + <video + id="preview" + className={styles.preview} + ref={(ref) => { this.video = ref; }} + autoPlay + playsInline + muted + /> + ) + } + </div> + {this.renderDeviceSelectors()} + </div> + ); + } + } + + renderModalContent() { + const { + intl, + } = this.props; + + const { isStartSharingDisabled, } = this.state; @@ -483,70 +582,8 @@ class VideoPreview extends Component { <div className={styles.title}> {intl.formatMessage(intlMessages.webcamSettingsTitle)} </div> - <div className={styles.content}> - <div className={styles.col}> - <video - id="preview" - className={styles.preview} - ref={(ref) => { this.video = ref; }} - autoPlay - playsInline - muted - /> - </div> - <div className={styles.col}> - <label className={styles.label} htmlFor="setCam"> - {intl.formatMessage(intlMessages.cameraLabel)} - </label> - { - availableWebcams && availableWebcams.length > 0 - ? ( - <select - id="setCam" - value={webcamDeviceId || ''} - className={styles.select} - onChange={this.handleSelectWebcam.bind(this)} - > - {availableWebcams.map(webcam => ( - <option key={webcam.deviceId} value={webcam.deviceId}> - {webcam.label} - </option> - ))} - </select> - ) - : ( - <span> - {intl.formatMessage(intlMessages.webcamNotFoundLabel)} - </span> - ) - } - <label className={styles.label} htmlFor="setQuality"> - {intl.formatMessage(intlMessages.qualityLabel)} - </label> - { - availableProfiles && availableProfiles.length > 0 - ? ( - <select - id="setQuality" - value={selectedProfile || ''} - className={styles.select} - onChange={this.handleSelectProfile.bind(this)} - > - {availableProfiles.map(profile => ( - <option key={profile.id} value={profile.id}> - {profile.name} - </option> - ))} - </select> - ) - : ( - <span> - {intl.formatMessage(intlMessages.profileNotFoundLabel)} - </span> - ) - } - </div> - </div> + + {this.renderContent()} <div className={styles.footer}> <div className={styles.actions}> @@ -557,7 +594,7 @@ class VideoPreview extends Component { <Button color="primary" label={intl.formatMessage(intlMessages.startSharingLabel)} - onClick={() => this.handleStartSharing()} + onClick={this.handleStartSharing} disabled={isStartSharingDisabled || isStartSharingDisabled === null} /> </div> diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss b/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss index d8ad92a0432e6b6c7d5356552958a38b211d44a6..2332ec0e84cca8b8fc4da7b9f8da62245f99fa70 100755 --- a/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/video-preview/styles.scss @@ -58,26 +58,34 @@ display: flex; flex-direction: column; height: 100%; - margin: 0 1.5rem 0 0; justify-content: center; + margin: 0 0.75rem 0 0.75rem; - [dir="rtl"] & { - margin: 0 0 0 1.5rem; - } - - //width: 50%; + width: 45%; @include mq($small-only) { - width: 100%; + width: 90%; height: unset; } } +.videoCol { + @extend .col; + align-items: center; +} + .content { display: flex; - flex: 3; + height: 14rem; + max-height: 40vh; + justify-content: center; + align-items: center; @include mq($small-only) { flex-direction: column; + height: unset; + margin: 0; + min-height: 12rem; + max-height: unset; } } @@ -107,10 +115,11 @@ } .preview { - width: 12rem; - height: 9rem; + height: 100%; + @include mq($small-only) { width: 100%; + height: 10rem; } } @@ -143,4 +152,29 @@ border-radius: 0.25rem; margin: var(--line-height-computed); text-align: center; +} + +.fetchingAnimation { + margin: auto; + display: inline-block; + width: 1.5em; + + &:after { + overflow: hidden; + display: inline-block; + vertical-align: bottom; + content: "\2026"; /* ascii code for the ellipsis character */ + width: 0; + margin-left: 0.25em; + + :global(.animationsEnabled) & { + animation: ellipsis steps(4, end) 900ms infinite; + } + } +} + +@keyframes ellipsis { + to { + width: 1.5em; + } } \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 1608848c796714c1d4f2869d026df06fbe547829..f5b87acb68d7a5ab27efc61694f9f3f1b703c729 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -124,6 +124,7 @@ class VideoProvider extends Component { || CAMERA_PROFILES.find(profile => profile.default) || CAMERA_PROFILES[0]; if (Session.get('WebcamDeviceId')) { + cameraProfile.constraints = cameraProfile.constraints || {}; cameraProfile.constraints.deviceId = { exact: Session.get('WebcamDeviceId') }; } diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 7f2e92e5b0035e7912ae6f5c437fa799f4888de2..d43f7af1a758f76e7770eb0434472d8004871ce6 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -96,38 +96,18 @@ public: - id: low name: Low quality default: false - constraints: - width: - max: 160 - height: - max: 120 bitrate: 100 - id: medium name: Medium quality default: true - constraints: - width: - max: 320 - height: - max: 240 bitrate: 200 - id: high name: High quality default: false - constraints: - width: - max: 640 - height: - max: 480 bitrate: 500 - id: hd name: High definition default: false - constraints: - width: - max: 1280 - height: - max: 960 bitrate: 800 enableScreensharing: true enableVideo: true diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index 37cdff4cffd776b4b27a16fc48e1744bf6656ccd..5333c1811d4b454e57cf7ab2826726a60041c589 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -521,6 +521,7 @@ "app.videoPreview.profileLabel": "Quality", "app.videoPreview.cancelLabel": "Cancel", "app.videoPreview.closeLabel": "Close", + "app.videoPreview.findingWebcamsLabel": "Finding webcams", "app.videoPreview.startSharingLabel": "Start sharing", "app.videoPreview.webcamOptionLabel": "Choose webcam", "app.videoPreview.webcamPreviewLabel": "Webcam preview",