// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import WebrtcStore from 'stores/webrtc_store.jsx'; import Client from 'client/web_client.jsx'; import WebSocketClient from 'client/web_websocket_client.jsx'; import Janus from 'janus'; import SearchBox from '../search_bar.jsx'; import WebrtcHeader from './components/webrtc_header.jsx'; import ConnectingScreen from 'components/loading_screen.jsx'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; import * as WebrtcActions from 'actions/webrtc_actions.jsx'; import * as Utils from 'utils/utils.jsx'; import {Constants, UserStatuses, WebrtcActionTypes} from 'utils/constants.jsx'; import React from 'react'; import {FormattedMessage} from 'react-intl'; import ring from 'images/ring.mp3'; const VIDEO_WIDTH = 640; const VIDEO_HEIGHT = 360; const MIN_ASPECT = 1.777; const MAX_ASPECT = 1.778; const ALREADY_REGISTERED_ERROR = 477; const USERNAME_TAKEN = 476; export default class WebrtcController extends React.Component { constructor(props) { super(props); this.mounted = false; this.localMedia = null; this.session = null; this.videocall = null; this.handleResize = this.handleResize.bind(this); this.handleClose = this.handleClose.bind(this); this.close = this.close.bind(this); this.clearError = this.clearError.bind(this); this.getLocalMedia = this.getLocalMedia.bind(this); this.stopMediaStream = this.stopMediaStream.bind(this); this.previewVideo = this.previewVideo.bind(this); this.stopRinging = this.stopRinging.bind(this); this.handleMakeOffer = this.handleMakeOffer.bind(this); this.handleCancelOffer = this.handleCancelOffer.bind(this); this.handleWebrtcEvent = this.handleWebrtcEvent.bind(this); this.handleVideoCallEvent = this.handleVideoCallEvent.bind(this); this.handleRemoteStream = this.handleRemoteStream.bind(this); this.onStatusChange = this.onStatusChange.bind(this); this.onCallDeclined = this.onCallDeclined.bind(this); this.onUnsupported = this.onUnsupported.bind(this); this.onNoAnswer = this.onNoAnswer.bind(this); this.onBusy = this.onBusy.bind(this); this.onDisabled = this.onDisabled.bind(this); this.onFailed = this.onFailed.bind(this); this.onCancelled = this.onCancelled.bind(this); this.onConnectCall = this.onConnectCall.bind(this); this.onSessionCreated = this.onSessionCreated.bind(this); this.onSessionError = this.onSessionError.bind(this); this.doCall = this.doCall.bind(this); this.doAnswer = this.doAnswer.bind(this); this.doHangup = this.doHangup.bind(this); this.doCleanup = this.doCleanup.bind(this); this.renderButtons = this.renderButtons.bind(this); this.onToggleVideo = this.onToggleVideo.bind(this); this.onToggleAudio = this.onToggleAudio.bind(this); this.onToggleRemoteMute = this.onToggleRemoteMute.bind(this); this.toggleIcons = this.toggleIcons.bind(this); const currentUser = UserStore.getCurrentUser(); const remoteUser = UserStore.getProfile(props.userId); const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.last_picture_update; this.state = { windowWidth: Utils.windowWidth(), windowHeight: Utils.windowHeight(), channelId: ChannelStore.getCurrentId(), currentUser, currentUserImage: Client.getUsersRoute() + '/' + currentUser.id + '/image?time=' + currentUser.last_picture_update, remoteUserImage, localMediaLoaded: false, isPaused: false, isMuted: false, isRemotePaused: false, isRemoteMuted: false, isCalling: false, isAnswering: false, callInProgress: false, error: null, errorType: '', ended: null }; } componentDidMount() { window.addEventListener('resize', this.handleResize); WebrtcStore.addChangedListener(this.handleWebrtcEvent); UserStore.addStatusesChangeListener(this.onStatusChange); this.mounted = true; this.previewVideo(); if (this.props.isCaller) { this.handleMakeOffer(); } } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); WebrtcStore.removeChangedListener(this.handleWebrtcEvent); UserStore.removeStatusesChangeListener(this.onStatusChange); this.mounted = false; this.close(); } componentWillReceiveProps(nextProps) { if ((nextProps.currentUser !== this.props.currentUser) || (nextProps.userId !== this.props.userId) || (nextProps.isCaller !== this.props.isCaller)) { const remoteUser = UserStore.getProfile(nextProps.userId); const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.last_picture_update; this.setState({ error: null, remoteUserImage }); } if (nextProps.isCaller && nextProps.expanded === this.props.expanded) { this.startCall = true; } } componentDidUpdate() { if (this.props.isCaller && this.startCall) { this.startCall = false; this.handleMakeOffer(); } } handleResize() { this.setState({ windowWidth: Utils.windowWidth(), windowHeight: Utils.windowHeight() }); } clearError() { setTimeout(() => { this.setState({error: null, ended: null, errorType: ''}); }, Constants.WEBRTC_CLEAR_ERROR_DELAY); } getLocalMedia(constraints, element, callback) { const media = constraints || {audio: true, video: true}; navigator.mediaDevices.getUserMedia(media). then((stream) => { if (element) { element.srcObject = stream; } if (callback && typeof callback === 'function') { callback(null, stream); } }). catch((error) => { callback(error); }); } stopMediaStream(stream) { const tracks = stream.getTracks(); tracks.forEach((track) => { track.stop(); }); } previewVideo() { if (this.mounted) { if (this.localMedia) { this.setState({ localMediaLoaded: true, error: null }); this.localMedia.enabled = true; } else { this.getLocalMedia( { audio: true, video: { minAspectRatio: MIN_ASPECT, maxAspectRatio: MAX_ASPECT, width: VIDEO_WIDTH, height: VIDEO_HEIGHT } }, this.refs['local-video'], (error, stream) => { if (error) { this.setState({ error: ( ) }); return; } this.localMedia = stream; this.setState({ localMediaLoaded: true }); }); } } } stopRinging() { if (this.refs.ring) { this.refs.ring.pause(); this.refs.ring.currentTime = 0; } } handleMakeOffer() { if (UserStore.getStatus(this.props.userId) === UserStatuses.OFFLINE) { this.onStatusChange(); } else { const connectingMsg = ( ); this.setState({ isCalling: true, isAnswering: false, callInProgress: false, error: null, ended: null, connectingMsg }); WebrtcStore.setVideoCallWith(this.props.userId); const user = this.state.currentUser; WebSocketClient.sendMessage('webrtc', { action: WebrtcActionTypes.NOTIFY, from_user_id: user.id, to_user_id: this.props.userId }); } } handleCancelOffer() { this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error: null, ended: null }); const user = this.state.currentUser; WebSocketClient.sendMessage('webrtc', { action: WebrtcActionTypes.CANCEL, from_user_id: user.id, to_user_id: this.props.userId }); this.doCleanup(); } handleWebrtcEvent(message) { switch (message.action) { case WebrtcActionTypes.DECLINE: this.onCallDeclined(); this.clearError(); break; case WebrtcActionTypes.UNSUPPORTED: this.onUnsupported(); this.clearError(); break; case WebrtcActionTypes.BUSY: this.onBusy(); this.clearError(); break; case WebrtcActionTypes.NO_ANSWER: this.onNoAnswer(); this.clearError(); break; case WebrtcActionTypes.FAILED: this.onFailed(); this.clearError(); break; case WebrtcActionTypes.ANSWER: this.onConnectCall(); break; case WebrtcActionTypes.CANCEL: this.onCancelled(); this.clearError(); break; case WebrtcActionTypes.MUTED: this.onToggleRemoteMute(message); break; case WebrtcActionTypes.IN_PROGRESS: this.setState({ error: ( ), errorType: ' warning' }); this.clearError(); break; case WebrtcActionTypes.DISABLED: this.onDisabled(); this.clearError(); break; } } handleVideoCallEvent(msg, jsep) { const result = msg.result; if (result) { const event = result.event; switch (event) { case 'registered': if (this.state.isCalling) { this.doCall(); } break; case 'incomingcall': this.doAnswer(jsep); break; case 'accepted': this.stopRinging(); if (jsep) { this.videocall.handleRemoteJsep({jsep}); } break; case 'hangup': this.doHangup(false); break; } } else { const errorCode = msg.error_code; if (errorCode !== ALREADY_REGISTERED_ERROR && errorCode !== USERNAME_TAKEN) { this.doHangup(true); } else if (this.state.isCalling) { this.doCall(); } } } handleRemoteStream(stream) { // attaching stream to where they belong this.refs['main-video'].srcObject = stream; let isRemotePaused = false; let isRemoteMuted = false; const videoTracks = stream.getVideoTracks(); const audioTracks = stream.getAudioTracks(); if (!videoTracks || videoTracks.length === 0 || videoTracks[0].muted || !videoTracks[0].enabled) { isRemotePaused = true; } if (!audioTracks || audioTracks.length === 0 || audioTracks[0].muted || !audioTracks[0].enabled) { isRemoteMuted = true; } this.setState({ isCalling: false, isAnswering: false, callInProgress: true, isMuted: false, isPaused: false, error: null, ended: null, isRemotePaused, isRemoteMuted }); this.toggleIcons(); } handleClose(e) { e.preventDefault(); if (this.state.callInProgress) { this.setState({ error: ( ), errorType: ' warning' }); } else if (this.state.isCalling) { this.handleCancelOffer(); this.close(); } else { this.close(); } } close() { this.doCleanup(); if (this.session) { this.session.destroy(); this.session = null; } if (this.localMedia) { this.stopMediaStream(this.localMedia); this.localMedia = null; } WebrtcActions.initWebrtc(null, false); } onStatusChange() { const status = UserStore.getStatus(this.props.userId); if (status === UserStatuses.OFFLINE) { const error = ( ); if (this.state.isCalling || this.state.isAnswering) { this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error }); } else { this.setState({ error }); } } else if (status !== UserStatuses.OFFLINE && this.state.error) { this.setState({ error: null, ended: null }); } } onCallDeclined() { let error = null; if (this.state.isCalling) { error = ( ); } this.stopRinging(); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error }); this.doCleanup(); } onUnsupported() { if (this.mounted) { this.stopRinging(); this.setState({ error: ( ), callInProgress: false, isCalling: false, isAnswering: false }); } this.doCleanup(); } onNoAnswer() { let error = null; if (this.state.isCalling) { error = ( ); } this.stopRinging(); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error }); this.doCleanup(); } onBusy() { let error = null; if (this.state.isCalling) { error = ( ); } this.stopRinging(); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error }); this.doCleanup(); } onDisabled() { let error = null; if (this.state.isCalling) { error = ( ); } this.stopRinging(); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error }); this.doCleanup(); } onFailed() { trackEvent('api', 'api_users_webrtc_failed'); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, isPaused: false, isMuted: false, isRemotePaused: false, isRemoteMuted: false, error: ( ) }); this.stopRinging(); this.doCleanup(); } onCancelled() { if (this.mounted && this.state.isAnswering) { this.stopRinging(); this.setState({ isCalling: false, isAnswering: false, callInProgress: false, error: ( ) }); } this.doCleanup(); } onConnectCall() { WebrtcActions.webrtcToken( (info) => { const connectingMsg = ( ); this.setState({isAnswering: !this.state.isCalling, connectingMsg}); if (this.session) { this.onSessionCreated(); } else { const iceServers = []; if (info.stun_uri) { iceServers.push({ urls: [info.stun_uri] }); } if (info.turn_uri) { iceServers.push({ urls: [info.turn_uri], username: info.turn_username, credential: info.turn_password }); } Janus.init({debug: global.mm_config.EnableDeveloper === 'true'}); this.session = new Janus({ server: info.gateway_url, iceServers, token: info.token, success: this.onSessionCreated, error: this.onSessionError }); } }, () => { this.onSessionError(); }); } onSessionCreated() { if (this.videocall) { this.doCall(); } else { this.session.attach({ plugin: 'janus.plugin.videocall', success: (plugin) => { this.videocall = plugin; this.videocall.send({message: {request: 'register', username: this.state.currentUser.id}}); }, error: this.onSessionError, onmessage: this.handleVideoCallEvent, onremotestream: this.handleRemoteStream }); } } onSessionError() { const user = this.state.currentUser; WebSocketClient.sendMessage('webrtc', { action: WebrtcActionTypes.FAILED, from_user_id: user.id, to_user_id: this.props.userId }); this.onFailed(); } doCall() { // delay call so receiver has time to register setTimeout(() => { this.videocall.createOffer({ stream: this.localMedia, success: (jsep) => { const body = {request: 'call', username: this.props.userId}; this.videocall.send({message: body, jsep}); }, error: () => { this.doHangup(true); } }); }, Constants.WEBRTC_TIME_DELAY); } doAnswer(jsep) { trackEvent('api', 'api_users_webrtc_start'); this.videocall.createAnswer({ jsep, stream: this.localMedia, success: (jsepSuccess) => { const body = {request: 'accept'}; this.videocall.send({message: body, jsep: jsepSuccess}); }, error: () => { this.doHangup(true); } }); } doHangup(error, manual) { trackEvent('api', 'api_users_webrtc_end'); if (this.videocall && this.state.callInProgress) { this.videocall.send({message: {request: 'hangup'}}); this.videocall.hangup(); this.toggleIcons(); if (this.localMedia) { this.localMedia.getVideoTracks()[0].enabled = true; this.localMedia.getAudioTracks()[0].enabled = true; } } if (error) { this.onSessionError(); return this.doCleanup(); } WebrtcStore.setVideoCallWith(null); WebrtcStore.emitRhsChanged(false); if (manual) { return this.close(); } this.setState({ isCalling: false, isAnswering: false, callInProgress: false, isPaused: false, isMuted: false, isRemotePaused: false, isRemoteMuted: false, error: null, ended: ( ) }); this.clearError(); return this.doCleanup(); } doCleanup() { WebrtcStore.setVideoCallWith(null); if (this.videocall) { this.videocall.detach(); this.videocall = null; } } onToggleVideo() { const shouldPause = !this.state.isPaused; if (shouldPause) { this.videocall.unmuteVideo(); } else { this.videocall.muteVideo(); } const user = this.state.currentUser; WebSocketClient.sendMessage('webrtc', { action: WebrtcActionTypes.MUTED, from_user_id: user.id, to_user_id: this.props.userId, type: 'video', mute: shouldPause }); this.setState({ isPaused: shouldPause, error: null, ended: null }); } onToggleAudio() { const shouldMute = !this.state.isMuted; if (shouldMute) { this.videocall.unmuteAudio(); } else { this.videocall.muteAudio(); } const user = this.state.currentUser; WebSocketClient.sendMessage('webrtc', { action: WebrtcActionTypes.MUTED, from_user_id: user.id, to_user_id: this.props.userId, type: 'audio', mute: shouldMute }); this.setState({ isMuted: shouldMute, error: null, ended: null }); } onToggleRemoteMute(message) { if (message.type === 'video') { this.setState({ isRemotePaused: message.mute }); } else { this.setState({isRemoteMuted: message.mute, error: null, ended: null}); } } toggleIcons() { const icons = this.refs.icons; if (icons) { icons.classList.toggle('hidden'); icons.classList.toggle('active'); } } renderButtons() { let buttons; if (this.state.isCalling) { buttons = ( this.handleCancelOffer()} > ); } else if (!this.state.callInProgress && this.state.localMediaLoaded) { buttons = ( this.handleMakeOffer()} disabled={UserStore.getStatus(this.props.userId) === 'offline'} > ); } else if (this.state.callInProgress) { const onClass = 'on'; const offClass = 'off'; let audioOnClass = offClass; let audioOffClass = onClass; let videoOnClass = offClass; let videoOffClass = onClass; let audioTitle = ( ); let videoTitle = ( ); if (this.state.isMuted) { audioOnClass = onClass; audioOffClass = offClass; audioTitle = ( ); } if (this.state.isPaused) { videoOnClass = onClass; videoOffClass = offClass; videoTitle = ( ); } buttons = (
this.onToggleAudio()} > {audioTitle} this.onToggleVideo()} > {videoTitle} this.doHangup(false, true)} > ); } return buttons; } render() { const currentId = UserStore.getCurrentId(); const remoteImage = (); let localImage; let localVideoHidden = ''; let remoteVideoHidden = 'hidden'; let error; let remoteMute; let localImageHidden = 'webrtc__local-image hidden'; let remoteImageHidden = 'webrtc__remote-image'; if (this.state.error) { error = (
); } else if (this.state.ended) { error = (
); } if (this.state.isRemoteMuted) { remoteMute = (
); } let searchForm; if (currentId != null) { searchForm = ; } const buttons = this.renderButtons(); const calling = this.state.isCalling; let connecting; let audio; if (calling || this.state.isAnswering) { if (calling) { audio = (