import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { defineMessages, injectIntl, FormattedMessage, } from 'react-intl'; import Button from '/imports/ui/components/button/component'; import logger from '/imports/startup/client/logger'; import Modal from '/imports/ui/components/modal/simple/component'; import Bowser from 'bowser'; import cx from 'classnames'; import Service from './service'; import VideoService from '../video-provider/service'; import { styles } from './styles'; const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles; const GUM_TIMEOUT = Meteor.settings.public.kurento.gUMTimeout; const VIEW_STATES = { finding: 'finding', found: 'found', error: 'error', }; const propTypes = { intl: PropTypes.object.isRequired, closeModal: PropTypes.func.isRequired, startSharing: PropTypes.func.isRequired, stopSharing: PropTypes.func.isRequired, changeWebcam: PropTypes.func.isRequired, changeProfile: PropTypes.func.isRequired, resolve: PropTypes.func, hasMediaDevices: PropTypes.bool.isRequired, hasVideoStream: PropTypes.bool.isRequired, webcamDeviceId: PropTypes.string, sharedDevices: PropTypes.arrayOf(PropTypes.string), }; const defaultProps = { resolve: null, webcamDeviceId: null, sharedDevices: [], }; 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', }, qualityLabel: { id: 'app.videoPreview.profileLabel', description: 'Quality dropdown label', }, low: { id: 'app.videoPreview.quality.low', description: 'Low quality option label', }, medium: { id: 'app.videoPreview.quality.medium', description: 'Medium quality option label', }, high: { id: 'app.videoPreview.quality.high', description: 'High quality option label', }, hd: { id: 'app.videoPreview.quality.hd', description: 'High definition option label', }, cancelLabel: { id: 'app.videoPreview.cancelLabel', description: 'Cancel button label', }, startSharingLabel: { id: 'app.videoPreview.startSharingLabel', description: 'Start sharing button label', }, stopSharingLabel: { id: 'app.videoPreview.stopSharingLabel', description: 'Stop sharing button label', }, stopSharingAllLabel: { id: 'app.videoPreview.stopSharingAllLabel', description: 'Stop sharing all button label', }, sharedCameraLabel: { id: 'app.videoPreview.sharedCameraLabel', description: 'Already Shared camera label', }, findingWebcamsLabel: { id: 'app.videoPreview.findingWebcamsLabel', description: 'Finding webcams label', }, webcamOptionLabel: { id: 'app.videoPreview.webcamOptionLabel', description: 'Default webcam option label', }, webcamNotFoundLabel: { id: 'app.videoPreview.webcamNotFoundLabel', description: 'Webcam not found label', }, profileNotFoundLabel: { id: 'app.videoPreview.profileNotFoundLabel', description: 'Profile not found label', }, permissionError: { id: 'app.video.permissionError', description: 'Error message for webcam permission', }, AbortError: { id: 'app.video.abortError', description: 'Some problem occurred which prevented the device from being used', }, OverconstrainedError: { id: 'app.video.overconstrainedError', description: 'No candidate devices which met the criteria requested', }, SecurityError: { id: 'app.video.securityError', description: 'Media support is disabled on the Document', }, TypeError: { id: 'app.video.typeError', description: 'List of constraints specified is empty, or has all constraints set to false', }, NotFoundError: { id: 'app.video.notFoundError', description: 'error message when can not get webcam video', }, NotAllowedError: { id: 'app.video.notAllowed', description: 'error message when webcam had permission denied', }, NotSupportedError: { id: 'app.video.notSupportedError', description: 'error message when origin do not have ssl valid', }, NotReadableError: { id: 'app.video.notReadableError', description: 'error message When the webcam is being used by other software', }, TimeoutError: { id: 'app.video.timeoutError', description: 'error message when promise did not return', }, iOSError: { id: 'app.audioModal.iOSBrowser', description: 'Audio/Video Not supported warning', }, iOSErrorDescription: { id: 'app.audioModal.iOSErrorDescription', description: 'Audio/Video not supported description', }, iOSErrorRecommendation: { id: 'app.audioModal.iOSErrorRecommendation', description: 'Audio/Video recommended action', }, genericError: { id: 'app.video.genericError', description: 'error message for when the webcam sharing fails with unknown error', }, }); class VideoPreview extends Component { constructor(props) { super(props); const { webcamDeviceId, } = props; this.handleProceed = this.handleProceed.bind(this); this.handleStartSharing = this.handleStartSharing.bind(this); this.handleStopSharing = this.handleStopSharing.bind(this); this.handleStopSharingAll = this.handleStopSharingAll.bind(this); this.handleSelectWebcam = this.handleSelectWebcam.bind(this); this.handleSelectProfile = this.handleSelectProfile.bind(this); this.deviceStream = null; this._isMounted = false; this.state = { webcamDeviceId, availableWebcams: null, availableProfiles: {}, selectedProfile: null, isStartSharingDisabled: true, viewState: VIEW_STATES.finding, deviceError: null, previewError: null, }; this.userParameterProfile = VideoService.getUserParameterProfile(); this.mirrorOwnWebcam = VideoService.mirrorOwnWebcam(); this.skipVideoPreview = Service.getSkipVideoPreview(); } componentDidMount() { const { webcamDeviceId, hasMediaDevices, } = this.props; this._isMounted = true; // Have to request any device to get past checks before finding devices. If this is // skipped then we get devices with no labels if (hasMediaDevices) { try { let firstAllowedDeviceId; const constraints = { audio: false, video: { facingMode: 'user', }, }; Service.promiseTimeout(GUM_TIMEOUT, navigator.mediaDevices.getUserMedia(constraints)) .then((stream) => { if (!this._isMounted) return; this.deviceStream = stream; // try and get the deviceId for the initial stream if (stream.getVideoTracks) { const videoTracks = stream.getVideoTracks(); if (videoTracks.length > 0 && videoTracks[0].getSettings) { const trackSettings = videoTracks[0].getSettings(); firstAllowedDeviceId = trackSettings.deviceId; } } }).catch((error) => { this.handleDeviceError('initial_device', error, 'getting initial device'); }).finally(() => { navigator.mediaDevices.enumerateDevices().then((devices) => { const webcams = []; let initialDeviceId; VideoService.updateNumberOfDevices(devices); if (!this._isMounted) return; // set webcam devices.forEach((device) => { // Avoid duplicated devices const found = webcams.find(d => d.deviceId === device.deviceId); if (device.kind === 'videoinput' && !found) { 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`); if (initialDeviceId) { this.setState({ availableWebcams: webcams, }); this.displayInitialPreview(initialDeviceId); } if (!this.skipVideoPreview) { this.setState({ viewState: VIEW_STATES.found, }); } }).catch((error) => { this.handleDeviceError('enumerate', error, 'enumerating devices'); }); }); } catch (error) { this.handleDeviceError('grabbing', error, 'grabbing initial video stream'); } } else { // TODO: Add an error message when media is globablly disabled } } componentWillUnmount() { // console.log("unmounting video preview"); this.stopTracks(); this.deviceStream = null; if (this.video) { // console.log("clear video srcObject"); this.video.srcObject = null; } this._isMounted = false; } stopTracks() { // console.log("in stop tracks"); if (this.deviceStream) { // console.log("stopping tracks"); this.deviceStream.getTracks().forEach((track) => { // console.log("found track to stop"); track.stop(); }); } } handleSelectWebcam(event) { const webcamValue = event.target.value; this.displayInitialPreview(webcamValue); } handleSelectProfile(event) { const profileValue = event.target.value; const { webcamDeviceId } = this.state; const selectedProfile = CAMERA_PROFILES.find(profile => profile.id === profileValue); this.displayPreview(webcamDeviceId, selectedProfile); } handleStartSharing() { const { resolve, startSharing } = this.props; const { webcamDeviceId } = this.state; this.stopTracks(); startSharing(webcamDeviceId); if (resolve) resolve(); } handleStopSharing() { const { resolve, stopSharing } = this.props; const { webcamDeviceId } = this.state; this.stopTracks(); stopSharing(webcamDeviceId); if (resolve) resolve(); } handleStopSharingAll() { const { resolve, stopSharing } = this.props; this.stopTracks(); stopSharing(); if (resolve) resolve(); } handleProceed() { const { resolve, closeModal } = this.props; this.stopTracks(); closeModal(); if (resolve) resolve(); } handlePreviewError(logCode, error, description) { logger.warn({ logCode: `video_preview_${logCode}_error`, extraInfo: { errorName: error.name, errorMessage: error.message, }, }, `Error ${description}`); this.setState({ previewError: this.handleGUMError(error), }); } handleDeviceError(logCode, error, description) { logger.warn({ logCode: `video_preview_${logCode}_error`, extraInfo: { errorName: error.name, errorMessage: error.message, }, }, `Error ${description}`); this.setState({ viewState: VIEW_STATES.error, deviceError: this.handleGUMError(error), }); } handleGUMError(error) { const { intl } = this.props; logger.error({ logCode: 'video_preview_gum_failure', extraInfo: { errorName: error.name, errorMessage: error.message, }, }, 'getUserMedia failed in video-preview'); if (intlMessages[error.name]) { return intl.formatMessage(intlMessages[error.name]); } return intl.formatMessage(intlMessages.genericError, { 0: `${error.name}: ${error.message}` }); } displayInitialPreview(deviceId) { const { changeWebcam } = this.props; const availableProfiles = CAMERA_PROFILES.filter(p => !p.hidden); this.setState({ webcamDeviceId: deviceId, isStartSharingDisabled: true, availableProfiles, }); changeWebcam(deviceId); if (availableProfiles.length > 0) { const defaultProfile = availableProfiles.find(profile => profile.id === this.userParameterProfile) || availableProfiles.find(profile => profile.default) || availableProfiles[0]; this.displayPreview(deviceId, defaultProfile); } } doGUM(deviceId, profile) { const constraints = { audio: false, video: { ...profile.constraints }, }; constraints.video.deviceId = { exact: deviceId }; this.stopTracks(); if (this.video) { this.video.srcObject = null; } this.deviceStream = null; return Service.promiseTimeout(GUM_TIMEOUT, navigator.mediaDevices.getUserMedia(constraints)); } displayPreview(deviceId, profile) { const { changeProfile, } = this.props; this.setState({ selectedProfile: profile.id, isStartSharingDisabled: true, previewError: undefined, }); changeProfile(profile.id); if (this.skipVideoPreview) return this.handleStartSharing(); this.doGUM(deviceId, profile).then((stream) => { if (!this._isMounted) return; this.setState({ isStartSharingDisabled: false, }); this.video.srcObject = stream; this.deviceStream = stream; }).catch((error) => { this.handlePreviewError('do_gum_preview', error, 'displaying final selection'); }); } supportWarning() { const { intl } = this.props; return (
!

{intl.formatMessage(intlMessages.iOSError)}

{intl.formatMessage(intlMessages.iOSErrorDescription)}
{intl.formatMessage(intlMessages.iOSErrorRecommendation)}
); } renderDeviceSelectors() { const { intl, sharedDevices, } = this.props; const { webcamDeviceId, availableWebcams, availableProfiles, selectedProfile, } = this.state; const shared = sharedDevices.includes(webcamDeviceId); return (
{ availableWebcams && availableWebcams.length > 0 ? ( ) : ( {intl.formatMessage(intlMessages.webcamNotFoundLabel)} ) } { shared ? ( {intl.formatMessage(intlMessages.sharedCameraLabel)} ) : ( {availableProfiles && availableProfiles.length > 0 ? ( ) : ( {intl.formatMessage(intlMessages.profileNotFoundLabel)} ) } ) }
); } renderContent() { const { intl, } = this.props; const { viewState, deviceError, previewError, } = this.state; switch (viewState) { case VIEW_STATES.finding: return (
{intl.formatMessage(intlMessages.findingWebcamsLabel)}
); case VIEW_STATES.error: return (
{deviceError}
); case VIEW_STATES.found: default: return (
{ previewError ? (
{previewError}
) : (
{this.renderDeviceSelectors()}
); } } renderModalContent() { const { intl, sharedDevices, hasVideoStream, } = this.props; const { isStartSharingDisabled, webcamDeviceId, deviceError, previewError, } = this.state; const shouldDisableButtons = this.skipVideoPreview && !(deviceError || previewError); const shared = sharedDevices.includes(webcamDeviceId); const BROWSER_RESULTS = Bowser.parse(window.navigator.userAgent); return (
{BROWSER_RESULTS.browser.name === 'Microsoft Edge' || BROWSER_RESULTS.browser.name === 'Internet Explorer' ? (

Chrome, 1: Firefox, }} />

) : null}
{intl.formatMessage(intlMessages.webcamSettingsTitle)}
{this.renderContent()}
{hasVideoStream ? (
) : null }
); } render() { const { intl, hasMediaDevices, isCamLocked, } = this.props; if (isCamLocked === true) { this.handleProceed(); return null; } if (this.skipVideoPreview) { return null; } const { deviceError, previewError, } = this.state; const allowCloseModal = !!(deviceError || previewError) || !this.skipVideoPreview; return ( {hasMediaDevices ? this.renderModalContent() : this.supportWarning() } ); } } VideoPreview.propTypes = propTypes; VideoPreview.defaultProps = defaultProps; export default injectIntl(VideoPreview);