From 781ff323db4c70e4ca476f9ef13a04e5aa063585 Mon Sep 17 00:00:00 2001 From: enahum Date: Fri, 16 Sep 2016 15:35:13 -0300 Subject: Webrtc client side (#4026) * WebRTC Server side * WebRTC client side * Bug fixes and improvements * Pushing UI improvements for webrtc (#3728) * Pushing UI improvements for webrtc * Updating webrtc css * PLT-3943 WebRTC P1: bug fixes and improvements * Video resolution set to std, reduce volume of ringtone and flip video horizontally * Fix calling a user B while WebRTC RHS is still opened * Leave RHS opened when call ends, Fix isBusy on popover and channel_header * Fix pre-release feature, RHS & System Console * PLT-3945 - Updating UI for webrtc (#3908) * PLT-3943 Webrtc p1 * Add ongoing call indicator when RHS is opened * UI updates to to webrtc notifcation (#3959) --- webapp/client/webrtc_session.jsx | 1966 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1966 insertions(+) create mode 100644 webapp/client/webrtc_session.jsx (limited to 'webapp/client/webrtc_session.jsx') diff --git a/webapp/client/webrtc_session.jsx b/webapp/client/webrtc_session.jsx new file mode 100644 index 000000000..1fccddff3 --- /dev/null +++ b/webapp/client/webrtc_session.jsx @@ -0,0 +1,1966 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import adapter from 'webrtc-adapter'; +import WebrtcClient from './webrtc_client.jsx'; +const transationLength = 12; + +export default class WebrtcSession { + static randomString(len) { + const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let randomString = ''; + for (let i = 0; i < len; i++) { + const randomPoz = Math.floor(Math.random() * charSet.length); + randomString += charSet.substring(randomPoz, randomPoz + 1); + } + return randomString; + } + + static getLocalMedia(constraints, element, callback) { + const media = constraints || {audio: true, video: true}; + navigator.mediaDevices.getUserMedia(media). + then((stream) => { + if (element) { + adapter.browserShim.attachMediaStream(element, stream); + } + + if (callback && typeof callback === 'function') { + callback(null, stream); + } + }). + catch((error) => { + callback(error); + }); + } + + static stopMediaStream(stream) { + const tracks = stream.getTracks(); + tracks.forEach((track) => { + track.stop(); + }); + } + + constructor(opts) { + // super(); + const options = opts || {}; + this.getServer = this.getServer.bind(this); + this.isConnected = this.isConnected.bind(this); + this.getSessionId = this.getSessionId.bind(this); + this.handleEvent = this.handleEvent.bind(this); + this.keepAlive = this.keepAlive.bind(this); + this.createSession = this.createSession.bind(this); + this.attach = this.attach.bind(this); + this.sendMessage = this.sendMessage.bind(this); + this.sendTrickleCandidate = this.sendTrickleCandidate.bind(this); + this.sendData = this.sendData.bind(this); + this.sendDtmf = this.sendDtmf.bind(this); + this.destroy = this.destroy.bind(this); + this.destroyHandle = this.destroyHandle.bind(this); + this.streamsDone = this.streamsDone.bind(this); + this.prepareWebrtc = this.prepareWebrtc.bind(this); + this.prepareWebrtcPeer = this.prepareWebrtcPeer.bind(this); + this.createOffer = this.createOffer.bind(this); + this.createAnswer = this.createAnswer.bind(this); + this.sendSDP = this.sendSDP.bind(this); + this.getVolume = this.getVolume.bind(this); + this.isMuted = this.isMuted.bind(this); + this.mute = this.mute.bind(this); + this.getBitrate = this.getBitrate.bind(this); + this.webrtcError = this.webrtcError.bind(this); + this.cleanupWebrtc = this.cleanupWebrtc.bind(this); + this.isAudioSendEnabled = this.isAudioSendEnabled.bind(this); + this.isAudioRecvEnabled = this.isAudioRecvEnabled.bind(this); + this.isVideoSendEnabled = this.isVideoSendEnabled.bind(this); + this.isVideoRecvEnabled = this.isVideoRecvEnabled.bind(this); + this.isDataEnabled = this.isDataEnabled.bind(this); + this.isTrickleEnabled = this.isTrickleEnabled.bind(this); + this.unbindWebSocket = this.unbindWebSocket.bind(this); + + this.websockets = false; + this.ws = null; + this.wsHandlers = {}; + this.wsKeepaliveTimeoutId = null; + this.servers = null; + this.server = null; + this.serversIndex = 0; + this.connected = false; + this.sessionId = null; + this.pluginHandles = {}; + this.retries = 0; + this.transactions = {}; + + this.client = new WebrtcClient(); + this.client.init({debug: options.debug}); + + this.gatewayCallbacks = options || {}; + this.gatewayCallbacks.success = (typeof options.success == 'function') ? options.success : this.client.noop; + this.gatewayCallbacks.error = (typeof options.error == 'function') ? options.error : this.client.noop; + this.gatewayCallbacks.destroyed = (typeof options.destroyed == 'function') ? options.destroyed : this.client.noop; + + if (!this.client.initDone) { + this.gatewayCallbacks.error('webrtc_client.not_initialize', 'Library not initialized'); + return {}; + } + + if (!this.client.isWebrtcSupported()) { + this.gatewayCallbacks.error('webrtc_client.browser.not_supported', 'WebRTC not supported by this browser'); + return {}; + } + + this.client.log('Library initialized: ' + this.client.initDone); + + if (!options.server) { + this.gatewayCallbacks.error('webrtc_client.invalid_gateway', 'Invalid gateway url'); + return {}; + } + + if (Array.isArray(options.server)) { + this.client.log('Multiple servers provided (' + options.server.length + '), will use the first that works'); + for (let i = 0; i < options.server; i++) { + const server = options.server[i]; + if (server.indexOf('ws') !== 0) { + this.gatewayCallbacks.error('webrtc_client.must_be_websocket', 'every server provided must be a websocket'); + return {}; + } + } + this.servers = options.server; + this.client.debug(this.servers); + } else if (options.server.indexOf('ws') === 0) { + this.websockets = true; + this.servers = [options.server]; + this.client.log('Using WebSockets to contact Janus: ' + options.server); + } else { + this.gatewayCallbacks.error('webrtc_client.invalid_websocket', 'This library must connect to a websocket'); + return {}; + } + + this.iceServers = options.iceServers; + if (!this.iceServers || this.iceServers.length === 0) { + this.iceServers = [{url: 'stun:stun.l.google.com:19302'}]; + } + + // Optional max events + this.maxev = null; + if (options.max_poll_events) { + this.maxev = options.max_poll_events; + } + if (this.maxev < 1) { + this.maxev = 1; + } + + // Token to use (only if the token based authentication mechanism is enabled) + this.token = null; + if (options.token) { + this.token = options.token; + } + + // API secret to use (only if the shared API secret is enabled) + this.apisecret = null; + if (options.apisecret) { + this.apisecret = options.apisecret; + } + + // Whether we should destroy this session when onbeforeunload is called + this.destroyOnUnload = options.destroyOnUnload !== false; + this.createSession(); + + return this; + } + + getServer() { + return this.server; + } + + isConnected() { + return this.connected; + } + + getSessionId() { + return this.sessionId; + } + + handleEvent(json) { + this.retries = 0; + this.client.debug('Got event on session ' + this.sessionId); + this.client.debug(json); + const transaction = json.transaction; + const sender = json.sender; + const plugindata = json.plugindata; + const jsep = json.jsep; + switch (json.janus) { + case 'keepalive': + // Nothing happened + break; + case 'ack': + case 'success': + // Success or just an ack, we can probably ignore + if (transaction) { + const reportSuccess = this.transactions[transaction]; + if (reportSuccess) { + reportSuccess(json); + } + Reflect.deleteProperty(this.transactions, transaction); + } + break; + case 'webrtcup': + // The PeerConnection with the gateway is up! Notify this + if (sender) { + const pluginHandle = this.pluginHandles[sender]; + if (pluginHandle) { + pluginHandle.webrtcState(true); + } else { + this.client.warn('This handle is not attached to this session'); + } + } else { + this.client.warn('Missing sender...'); + } + break; + case 'hangup': + // A plugin asked the core to hangup a PeerConnection on one of our handles + if (sender) { + const pluginHandle = this.pluginHandles[sender]; + if (pluginHandle) { + pluginHandle.webrtcState(false); + pluginHandle.hangup(); + } else { + this.client.warn('This handle is not attached to this session'); + } + } else { + this.client.warn('Missing sender...'); + } + break; + case 'detached': + // A plugin asked the core to detach one of our handles + if (sender) { + const pluginHandle = this.pluginHandles[sender]; + if (pluginHandle) { + pluginHandle.ondetached(); + pluginHandle.detach(); + } else { + this.client.warn('This handle is not attached to this session'); + } + } else { + this.client.warn('Missing sender...'); + } + break; + case 'media': + // Media started/stopped flowing + if (sender) { + const pluginHandle = this.pluginHandles[sender]; + if (pluginHandle) { + pluginHandle.mediaState(json.type, json.receiving); + } else { + this.client.warn('This handle is not attached to this session'); + } + } else { + this.client.warn('Missing sender...'); + } + break; + case 'error': + // Oops, something wrong happened + this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason); + if (transaction) { + const reportSuccess = this.transactions[transaction]; + if (reportSuccess) { + reportSuccess(json); + } + Reflect.deleteProperty(this.transactions, transaction); + } + break; + case 'event': + if (sender) { + if (plugindata) { + this.client.debug(` -- Event is coming from ${sender} ( ${plugindata.plugin} )`); + const data = plugindata.data; + this.client.debug(data); + const pluginHandle = this.pluginHandles[sender]; + if (pluginHandle) { + pluginHandle.mediaState(json.type, json.receiving); + if (jsep) { + this.client.debug('Handling SDP as well...'); + this.client.debug(jsep); + } + const callback = pluginHandle.onmessage; + if (callback) { + this.client.debug('Notifying application...'); + + // Send to callback specified when attaching plugin handle + callback(data, jsep); + } else { + // Send to generic callback (?) + this.client.debug('No provided notification callback'); + } + } else { + this.client.warn('This handle is not attached to this session'); + } + } else { + this.client.warn('Missing plugindata...'); + } + } else { + this.client.warn('Missing sender...'); + } + break; + default: + this.client.warn(`Unknown message "${json.janus}"`); + break; + } + } + + keepAlive() { + if (this.server === null || !this.websockets || !this.connected) { + return; + } + this.wsKeepaliveTimeoutId = setTimeout(this.keepAlive, 30000); + + const request = { + janus: 'keepalive', + session_id: this.sessionId, + transaction: WebrtcSession.randomString(transationLength) + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + this.ws.send(JSON.stringify(request)); + } + + createSession() { + const transaction = WebrtcSession.randomString(transationLength); + const request = { + janus: 'create', + transaction + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + if (this.server === null && Array.isArray(this.servers)) { + // We still need to find a working server from the list we were given + this.server = this.servers[this.serversIndex]; + if (this.server.indexOf('ws') === 0) { + this.websockets = true; + this.client.log('Server #' + (this.serversIndex + 1) + ': trying WebSockets to contact Janus (' + this.server + ')'); + } + } + + if (this.websockets) { + this.ws = new WebSocket(this.server, 'janus-protocol'); + this.wsHandlers = { + error: () => { + this.client.error('Error connecting to the Janus WebSockets server... ' + this.server); + if (Array.isArray(this.servers)) { + this.serversIndex++; + if (this.serversIndex === this.servers.length) { + // We tried all the servers the user gave us and they all failed + this.gatewayCallbacks.error('webrtc_client.cannot_connect_servers', 'Error connecting to any of the provided Janus servers: Is the gateway down?'); + return; + } + + // Let's try the next server + this.server = null; + setTimeout(() => { + this.createSession(); + }, 200); + return; + } + this.gatewayCallbacks.error('webrtc_client.cannot_connect_server', 'Error connecting to the Janus WebSockets server: Is the gateway down?'); + }, + + open: () => { + // We need to be notified about the success + this.transactions[transaction] = (json) => { + this.client.debug(json); + if (json.janus !== 'success') { + this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason); // FIXME + this.gatewayCallbacks.error(json.error.reason); + return; + } + this.wsKeepaliveTimeoutId = setTimeout(this.keepAlive, 30000); + this.connected = true; + this.sessionId = json.data.id; + this.client.log('Created session: ' + this.sessionId); + this.client.sessions[this.sessionId] = this; + this.gatewayCallbacks.success(); + }; + this.ws.send(JSON.stringify(request)); + }, + + message: (event) => { + this.handleEvent(JSON.parse(event.data)); + }, + + close: () => { + if (!this.connected) { + return; + } + this.connected = false; + + // FIXME What if this is called when the page is closed? + this.gatewayCallbacks.error('Lost connection to the gateway (is it down?)'); + } + }; + + for (var eventName in this.wsHandlers) { + if (this.wsHandlers.hasOwnProperty(eventName)) { + this.ws.addEventListener(eventName, this.wsHandlers[eventName]); + } + } + } + } + + attach(cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + callbacks.consentDialog = (typeof cbs.consentDialog == 'function') ? cbs.consentDialog : this.client.noop; + callbacks.mediaState = (typeof cbs.mediaState == 'function') ? cbs.mediaState : this.client.noop; + callbacks.webrtcState = (typeof cbs.webrtcState == 'function') ? cbs.webrtcState : this.client.noop; + callbacks.onmessage = (typeof cbs.onmessage == 'function') ? cbs.onmessage : this.client.noop; + callbacks.onlocalstream = (typeof cbs.onlocalstream == 'function') ? cbs.onlocalstream : this.client.noop; + callbacks.onremotestream = (typeof cbs.onremotestream == 'function') ? cbs.onremotestream : this.client.noop; + callbacks.ondata = (typeof cbs.ondata == 'function') ? cbs.ondata : this.client.noop; + callbacks.ondataopen = (typeof cbs.ondataopen == 'function') ? cbs.ondataopen : this.client.noop; + callbacks.oncleanup = (typeof cbs.oncleanup == 'function') ? cbs.oncleanup : this.client.noop; + callbacks.ondetached = (typeof cbs.ondetached == 'function') ? cbs.ondetached : this.client.noop; + + if (!this.connected) { + this.client.warn('Is the gateway down? (connected=false)'); + callbacks.error('Is the gateway down? (connected=false)'); + return; + } + + const plugin = callbacks.plugin; + if (!plugin) { + this.client.error('Invalid plugin'); + callbacks.error('Invalid plugin'); + return; + } + + const transaction = WebrtcSession.randomString(transationLength); + const request = { + janus: 'attach', + plugin, + transaction + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + if (this.websockets) { + this.transactions[transaction] = (json) => { + this.client.debug(json); + + if (json.janus !== 'success') { + const error = `Ooops: ${json.error.code} ${json.error.reason}`; + this.client.error(error); + callbacks.error(error); + return; + } + + const handleId = json.data.id; + + this.client.log('Created handle: ' + handleId); + const pluginHandle = { + session: this, + plugin, + id: handleId, + webrtcStuff: { + started: false, + myStream: null, + streamExternal: false, + remoteStream: null, + mySdp: null, + pc: null, + dataChannel: null, + dtmfSender: null, + trickle: true, + iceDone: false, + sdpSent: false, + volume: { + value: null, + timer: null + }, + bitrate: { + value: null, + bsnow: null, + bsbefore: null, + tsnow: null, + tsbefore: null, + timer: null + } + }, + getId: () => { + return handleId; + }, + getPlugin: () => { + return plugin; + }, + getVolume: () => { + return this.getVolume(handleId); + }, + isAudioMuted: () => { + return this.isMuted(handleId, false); + }, + muteAudio: () => { + return this.mute(handleId, false, true); + }, + unmuteAudio: () => { + return this.mute(handleId, false, false); + }, + isVideoMuted: () => { + return this.isMuted(handleId, true); + }, + muteVideo: () => { + return this.mute(handleId, true, true); + }, + unmuteVideo: () => { + return this.mute(handleId, true, false); + }, + getBitrate: () => { + return this.getBitrate(handleId); + }, + send: (cb) => { + this.sendMessage(handleId, cb); + }, + data: (cb) => { + this.sendData(handleId, cb); + }, + dtmf: (cb) => { + this.sendDtmf(handleId, cb); + }, + consentDialog: callbacks.consentDialog, + mediaState: callbacks.mediaState, + webrtcState: callbacks.webrtcState, + onmessage: callbacks.onmessage, + createOffer: (cb) => { + this.prepareWebrtc(handleId, cb); + }, + createAnswer: (cb) => { + this.prepareWebrtc(handleId, cb); + }, + handleRemoteJsep: (cb) => { + this.prepareWebrtcPeer(handleId, cb); + }, + onlocalstream: callbacks.onlocalstream, + onremotestream: callbacks.onremotestream, + ondata: callbacks.ondata, + ondataopen: callbacks.ondataopen, + oncleanup: callbacks.oncleanup, + ondetached: callbacks.ondetached, + hangup: (sendRequest) => { + this.cleanupWebrtc(handleId, sendRequest === true); + }, + detach: (cb) => { + this.destroyHandle(handleId, cb); + } + }; + this.pluginHandles[handleId] = pluginHandle; + callbacks.success(pluginHandle); + }; + request.session_id = this.sessionId; + this.ws.send(JSON.stringify(request)); + } + } + + sendMessage(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + + if (!this.connected) { + const error = 'Is the gateway down? (connected=false)'; + this.client.warn(error); + callbacks.error(error); + return; + } + + const message = callbacks.message; + const jsep = callbacks.jsep; + const transaction = WebrtcSession.randomString(transationLength); + const request = { + janus: 'message', + body: message, + transaction + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + if (jsep) { + request.jsep = jsep; + } + + this.client.debug('Sending message to plugin (handle=' + handleId + '):'); + this.client.debug(request); + + if (this.websockets) { + request.session_id = this.sessionId; + request.handle_id = handleId; + this.transactions[transaction] = (json) => { + this.client.debug('Message sent!'); + this.client.debug(json); + + if (json.janus === 'success') { + // We got a success, must have been a synchronous transaction + const plugindata = json.plugindata; + if (!plugindata) { + this.client.warn('Request succeeded, but missing plugindata...'); + callbacks.success(); + return; + } + + this.client.log('Synchronous transaction successful (' + plugindata.plugin + ')'); + const data = plugindata.data; + this.client.debug(data); + callbacks.success(data); + return; + } else if (json.janus !== 'ack') { + // Not a success and not an ack, must be an error + if (json.error) { + this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason); + callbacks.error(json.error.code + ' ' + json.error.reason); + } else { + this.client.error('Unknown error'); + callbacks.error('Unknown error'); + } + return; + } + + // If we got here, the plugin decided to handle the request asynchronously + callbacks.success(); + }; + this.ws.send(JSON.stringify(request)); + } + } + + sendTrickleCandidate(handleId, candidate) { + if (!this.connected) { + this.client.warn('Is the gateway down? (connected=false)'); + return; + } + var request = { + janus: 'trickle', + candidate, + transaction: WebrtcSession.randomString(transationLength) + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + this.client.debug('Sending trickle candidate (handle=' + handleId + '):'); + this.client.debug(request); + + if (this.websockets) { + request.session_id = this.sessionId; + request.handle_id = handleId; + this.ws.send(JSON.stringify(request)); + } + } + + sendData(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + const text = callbacks.text; + if (!text) { + this.client.warn('Invalid text'); + callbacks.error('Invalid text'); + return; + } + this.client.log('Sending string on data channel: ' + text); + config.dataChannel.send(text); + callbacks.success(); + } + + sendDtmf(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + if (!config.dtmfSender) { + // Create the DTMF sender, if possible + if (config.myStream) { + const tracks = config.myStream.getAudioTracks(); + if (tracks && tracks.length > 0) { + const localAudioTrack = tracks[0]; + config.dtmfSender = config.pc.createDTMFSender(localAudioTrack); + this.client.log('Created DTMF Sender'); + config.dtmfSender.ontonechange = (tone) => { + this.client.debug('Sent DTMF tone: ' + tone.tone); + }; + } + } + if (!config.dtmfSender) { + this.client.warn('Invalid DTMF configuration'); + callbacks.error('Invalid DTMF configuration'); + return; + } + } + + const dtmf = callbacks.dtmf; + if (!dtmf) { + this.client.warn('Invalid DTMF parameters'); + callbacks.error('Invalid DTMF parameters'); + return; + } + + const tones = dtmf.tones; + if (!tones) { + this.client.warn('Invalid DTMF string'); + callbacks.error('Invalid DTMF string'); + return; + } + + let duration = dtmf.duration; + if (!duration) { + duration = 500; // We choose 500ms as the default duration for a tone + } + + let gap = dtmf.gap; + if (!gap) { + gap = 50; // We choose 50ms as the default gap between tones + } + + this.client.debug('Sending DTMF string ' + tones + ' (duration ' + duration + 'ms, gap ' + gap + 'ms'); + config.dtmfSender.insertDTMF(tones, duration, gap); + } + + destroy(sync) { + const syncRequest = (sync === true); + this.client.log('Destroying session ' + this.sessionId); + + if (!this.connected) { + this.client.warn('Is the gateway down? (connected=false)'); + return; + } + + if (!this.sessionId) { + this.client.warn('No session to destroy'); + this.gatewayCallbacks.destroyed(); + return; + } + + Reflect.deleteProperty(this.client.sessions, this.sessionId); + + // Destroy all handles first + for (const ph in this.pluginHandles) { + if (this.pluginHandles.hasOwnProperty(ph)) { + const phv = this.pluginHandles[ph]; + this.client.log('Destroying handle ' + phv.id + ' (' + phv.plugin + ')'); + this.destroyHandle(phv.id, null, syncRequest); + } + } + + // Ok, go on + var request = {janus: 'destroy', transaction: WebrtcSession.randomString(transationLength)}; + + if (this.token) { + request.token = this.token; + } + if (this.apisecret) { + request.apisecret = this.apisecret; + } + if (this.websockets) { + request.session_id = this.sessionId; + + let onUnbindMessage = null; + let onUnbindError = null; + onUnbindMessage = (event) => { + var data = JSON.parse(event.data); + if (data.session_id === request.session_id && data.transaction === request.transaction) { + this.unbindWebSocket(onUnbindMessage, onUnbindError); + this.gatewayCallbacks.destroyed(); + } + }; + onUnbindError = () => { + this.unbindWebSocket(onUnbindMessage, onUnbindError); + this.gatewayCallbacks.destroyed(); + }; + + this.ws.addEventListener('message', onUnbindMessage); + this.ws.addEventListener('error', onUnbindError); + + this.ws.send(JSON.stringify(request)); + } + } + + disconnect() { + this.connected = false; + this.ws.close(); + } + + destroyHandle(handleId, cbs, sync) { + const syncRequest = (sync === true); + this.client.log('Destroying handle ' + handleId + ' (sync=' + syncRequest + ')'); + const callbacks = cbs || {}; + callbacks.success = (typeof callbacks.success == 'function') ? callbacks.success : this.client.noop; + callbacks.error = (typeof callbacks.error == 'function') ? callbacks.error : this.client.noop; + this.cleanupWebrtc(handleId); + if (!this.connected) { + this.client.warn('Is the gateway down? (connected=false)'); + return; + } + const request = { + janus: 'detach', + transaction: WebrtcSession.randomString(transationLength) + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + if (this.websockets) { + request.session_id = this.sessionId; + request.handle_id = handleId; + this.ws.send(JSON.stringify(request)); + + Reflect.deleteProperty(this.pluginHandles, handleId); + + callbacks.success(); + } + } + + streamsDone(handleId, jsep, media, callbacks, stream) { + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + this.client.debug('streamsDone:', stream); + config.myStream = stream; + + const pcConfig = {iceServers: this.iceServers}; + const pcConstraints = { + optional: [{DtlsSrtpKeyAgreement: true}] + }; + + this.client.log('Creating PeerConnection'); + this.client.debug(pcConstraints); + config.pc = new window.RTCPeerConnection(pcConfig, pcConstraints); + this.client.debug(config.pc); + if (config.pc.getStats) { // FIXME + config.volume.value = 0; + config.bitrate.value = '0 kbits/sec'; + } + this.client.log('Preparing local SDP and gathering candidates (trickle=' + config.trickle + ')'); + + config.pc.onicecandidate = (event) => { + if (!event.candidate || (adapter.browserDetails.browser === 'edge' && event.candidate.candidate.indexOf('endOfCandidates') > 0)) { + this.client.log('End of candidates.'); + config.iceDone = true; + if (config.trickle === true) { + // Notify end of candidates + this.sendTrickleCandidate(handleId, {completed: true}); + } else { + // No trickle, time to send the complete SDP (including all candidates) + this.sendSDP(handleId, callbacks); + } + } else { + // JSON.stringify doesn't work on some WebRTC objects anymore + // See https://code.google.com/p/chromium/issues/detail?id=467366 + const candidate = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + }; + + if (config.trickle === true) { + // Send candidate + this.sendTrickleCandidate(handleId, candidate); + } + } + }; + + if (stream) { + this.client.log('Adding local stream'); + config.pc.addStream(stream); + pluginHandle.onlocalstream(stream); + } + + config.pc.onaddstream = (remoteStream) => { + this.client.log('Handling Remote Stream'); + this.client.debug(remoteStream); + config.remoteStream = remoteStream; + pluginHandle.onremotestream(remoteStream.stream); + }; + + // Any data channel to create? + if (this.isDataEnabled(media)) { + this.client.log('Creating data channel'); + const onDataChannelMessage = (event) => { + this.client.log('Received message on data channel: ' + event.data); + pluginHandle.ondata(event.data); // FIXME + }; + + const onDataChannelStateChange = () => { + const dcState = config.dataChannel ? config.dataChannel.readyState : 'null'; + this.client.log('State change on data channel: ' + dcState); + if (dcState === 'open') { + pluginHandle.ondataopen(); // FIXME + } + }; + + const onDataChannelError = (error) => { + this.client.error('Got error on data channel:', error); + + // TODO + }; + + // Until we implement the proxying of open requests within the this.client core, we open a channel ourselves whatever the case + config.dataChannel = config.pc.createDataChannel('this.clientDataChannel', {ordered: false}); // FIXME Add options (ordered, maxRetransmits, etc.) + config.dataChannel.onmessage = onDataChannelMessage; + config.dataChannel.onopen = onDataChannelStateChange; + config.dataChannel.onclose = onDataChannelStateChange; + config.dataChannel.onerror = onDataChannelError; + } + + // Create offer/answer now DO I WANT THIS?? + if (jsep) { + config.pc.setRemoteDescription( + new RTCSessionDescription(jsep), + () => { + this.client.log('Remote description accepted!'); + this.createAnswer(handleId, media, callbacks); + }, callbacks.error); + } else { + this.createOffer(handleId, media, callbacks); + } + } + + prepareWebrtc(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.webrtcError; + const jsep = callbacks.jsep; + const media = callbacks.media; + const pluginHandle = this.pluginHandles[handleId]; + + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + + // Are we updating a session? + if (config.pc) { + this.client.log('Updating existing media session'); + + // Create offer/answer now + if (jsep) { + config.pc.setRemoteDescription( + new window.RTCSessionDescription(jsep), + () => { + this.client.log('Remote description accepted!'); + this.createAnswer(handleId, media, callbacks); + }, callbacks.error); + } else { + this.createOffer(handleId, media, callbacks); + } + return; + } + + // Was a MediaStream object passed, or do we need to take care of that? + if (callbacks.stream) { + const stream = callbacks.stream; + this.client.log('MediaStream provided by the application'); + this.client.debug(stream); + + // Skip the getUserMedia part + config.streamExternal = true; + this.streamsDone(handleId, jsep, media, callbacks, stream); + return; + } + + config.trickle = this.isTrickleEnabled(callbacks.trickle); + if (this.isAudioSendEnabled(media) || this.isVideoSendEnabled(media)) { + let constraints = {mandatory: {}, optional: []}; + pluginHandle.consentDialog(true); + + let audioSupport = this.isAudioSendEnabled(media); + if (audioSupport === true && media) { + if (typeof media.audio === 'object') { + audioSupport = media.audio; + } + } + + let videoSupport = this.isVideoSendEnabled(media); + if (videoSupport === true && media) { + if (media.video && media.video !== 'screen' && media.video !== 'window') { + let width = 0; + let height = 0; + let maxHeight = 0; + + if (media.video === 'lowres') { + // Small resolution, 4:3 + height = 240; + maxHeight = 240; + width = 320; + } else if (media.video === 'lowres-16:9') { + // Small resolution, 16:9 + height = 180; + maxHeight = 180; + width = 320; + } else if (media.video === 'hires' || media.video === 'hires-16:9') { + // High resolution is only 16:9 + height = 720; + maxHeight = 720; + width = 1280; + if (navigator.mozGetUserMedia) { + const firefoxVer = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10); + if (firefoxVer < 38) { + // Unless this is and old Firefox, which doesn't support it + this.client.warn(media.video + ' unsupported, falling back to stdres (old Firefox)'); + height = 480; + maxHeight = 480; + width = 640; + } + } + } else if (media.video === 'stdres') { + // Normal resolution, 4:3 + height = 480; + maxHeight = 480; + width = 640; + } else if (media.video === 'stdres-16:9') { + // Normal resolution, 16:9 + height = 360; + maxHeight = 360; + width = 640; + } else { + this.client.log('Default video setting (' + media.video + ') is stdres 4:3'); + height = 480; + maxHeight = 480; + width = 640; + } + + this.client.log('Adding media constraint ' + media.video); + + if (navigator.mozGetUserMedia) { + const firefoxVer = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10); + if (firefoxVer < 38) { + videoSupport = { + require: ['height', 'width'], + height: {max: maxHeight, min: height}, + width: {max: width, min: width} + }; + } else { + // http://stackoverflow.com/questions/28282385/webrtc-firefox-constraints/28911694#28911694 + // https://github.com/meetecho/janus-gateway/pull/246 + videoSupport = { + height: {ideal: height}, + width: {ideal: width} + }; + } + } else { + videoSupport = { + mandatory: { + maxHeight, + minHeight: height, + maxWidth: width, + minWidth: width + }, + optional: [] + }; + } + + if (typeof media.video === 'object') { + videoSupport = media.video; + } + + this.client.debug(videoSupport); + } else if (media.video === 'screen' || media.video === 'window') { + // Not a webcam, but screen capture + if (window.location.protocol !== 'https:') { + // Screen sharing mandates HTTPS + this.client.warn('Screen sharing only works on HTTPS, try the https:// version of this page'); + pluginHandle.consentDialog(false); + callbacks.error('Screen sharing only works on HTTPS, try the https:// version of this page'); + return; + } + + // We're going to try and use the extension for Chrome 34+, the old approach + // for older versions of Chrome, or the experimental support in Firefox 33+ + const cache = {}; + const self = this; + + function callbackUserMedia(error, stream) { + pluginHandle.consentDialog(false); + if (error) { + callbacks.error({code: error.code, name: error.name, message: error.message}); + } else { + self.streamsDone(handleId, jsep, media, callbacks, stream); + } + } + + function getScreenMedia(constraint, gsmCallback) { + this.client.log('Adding media constraint (screen capture)'); + this.client.debug(constraint); + navigator.mediaDevices.getUserMedia(constraint). + then((stream) => { + gsmCallback(null, stream); + }). + catch((error) => { + pluginHandle.consentDialog(false); + gsmCallback(error); + }); + } + + if (window.navigator.userAgent.match('Chrome')) { + const chromever = parseInt(window.navigator.userAgent.match(/Chrome\/(.*) /)[1], 10); + let maxver = 33; + + if (window.navigator.userAgent.match('Linux')) { + maxver = 35; // 'known' crash in chrome 34 and 35 on linux + } + + if (chromever >= 26 && chromever <= maxver) { + // Chrome 26->33 requires some awkward chrome://flags manipulation + constraints = { + video: { + mandatory: { + googLeakyBucket: true, + maxWidth: window.screen.width, + maxHeight: window.screen.height, + maxFrameRate: 3, + chromeMediaSource: 'screen' + } + }, + audio: this.isAudioSendEnabled(media) + }; + getScreenMedia(constraints, callbackUserMedia); + } else { + // Chrome 34+ requires an extension + var pending = window.setTimeout( + () => { + const error = new Error('NavigatorUserMediaError'); + error.name = 'The required Chrome extension is not installed: click here to install it. (NOTE: this will need you to refresh the page)'; + pluginHandle.consentDialog(false); + return callbacks.error(error); + }, 1000); + cache[pending] = [callbackUserMedia, null]; + window.postMessage({ + type: 'janusGetScreen', + id: pending + }, '*'); + } + } else if (window.navigator.userAgent.match('Firefox')) { + const ffver = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10); + if (ffver >= 33) { + // Firefox 33+ has experimental support for screen sharing + constraints = { + video: { + mozMediaSource: media.video, + mediaSource: media.video + }, + audio: this.isAudioSendEnabled(media) + }; + getScreenMedia(constraints, (err, stream) => { + callbackUserMedia(err, stream); + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1045810 + if (!err) { + let lastTime = stream.currentTime; + const polly = window.setInterval(() => { + if (!stream) { + window.clearInterval(polly); + } + + if (stream.currentTime === lastTime) { + window.clearInterval(polly); + if (stream.onended) { + stream.onended(); + } + } + + lastTime = stream.currentTime; + }, 500); + } + }); + } else { + const error = new Error('NavigatorUserMediaError'); + error.name = 'Your version of Firefox does not support screen sharing, please install Firefox 33 (or more recent versions)'; + pluginHandle.consentDialog(false); + callbacks.error(error); + return; + } + } + + // Wait for events from the Chrome Extension + window.addEventListener('message', (event) => { + if (event.origin !== window.location.origin) { + return; + } + if (event.data.type === 'mattermostGotScreen' && cache[event.data.id]) { + const data = cache[event.data.id]; + const callback = data[0]; + + Reflect.deleteProperty(cache, event.data.id); + + if (event.data.sourceId === '') { + // user canceled + const error = new Error('NavigatorUserMediaError'); + error.name = 'You cancelled the request for permission, giving up...'; + pluginHandle.consentDialog(false); + callbacks.error(error); + } else { + constraints = { + audio: this.isAudioSendEnabled(media), + video: { + mandatory: { + chromeMediaSource: 'desktop', + maxWidth: window.screen.width, + maxHeight: window.screen.height, + maxFrameRate: 3 + }, + optional: [ + {googLeakyBucket: true}, + {googTemporalLayeredScreencast: true} + ] + } + }; + constraints.video.mandatory.chromeMediaSourceId = event.data.sourceId; + getScreenMedia(constraints, callback); + } + } else if (event.data.type === 'mattermostGetScreenPending') { + window.clearTimeout(event.data.id); + } + }); + return; + } + } + + // If we got here, we're not screensharing + if (!media || media.video !== 'screen') { + // Check whether all media sources are actually available or not + navigator.mediaDevices.enumerateDevices().then((devices) => { + const audioExist = devices.some((device) => { + return device.kind === 'audioinput'; + }); + + const videoExist = devices.some((device) => { + return device.kind === 'videoinput'; + }); + + // Check whether a missing device is really a problem + const audioSend = this.isAudioSendEnabled(media); + const videoSend = this.isVideoSendEnabled(media); + + if (audioSend || videoSend) { + // We need to send either audio or video + const haveAudioDevice = audioSend ? audioExist : false; + const haveVideoDevice = videoSend ? videoExist : false; + + if (!haveAudioDevice && !haveVideoDevice) { + // FIXME Should we really give up, or just assume recvonly for both? + pluginHandle.consentDialog(false); + callbacks.error('No capture device found'); + return false; + } + } + + navigator.mediaDevices.getUserMedia({ + audio: audioExist ? audioSupport : false, + video: videoExist ? videoSupport : false + }). + then((stream) => { + pluginHandle.consentDialog(false); + this.streamsDone(handleId, jsep, media, callbacks, stream); + }). + catch((error) => { + pluginHandle.consentDialog(false); + callbacks.error({ + code: error.code, + name: error.name, + message: error.message + }); + }); + + return true; + }). + catch((error) => { + pluginHandle.consentDialog(false); + callbacks.error('enumerateDevices error', error); + }); + } + } else { + // No need to do a getUserMedia, create offer/answer right away + this.streamsDone(handleId, jsep, media, callbacks); + } + } + + prepareWebrtcPeer(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.webrtcError; + + const jsep = callbacks.jsep; + const pluginHandle = this.pluginHandles[handleId]; + + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + + if (jsep) { + if (config.pc === null) { + this.client.warn('Wait, no PeerConnection?? if this is an answer, use createAnswer and not handleRemoteJsep'); + callbacks.error('No PeerConnection: if this is an answer, use createAnswer and not handleRemoteJsep'); + return; + } + config.pc.setRemoteDescription( + new window.RTCSessionDescription(jsep), + () => { + this.client.log('Remote description accepted!'); + callbacks.success(); + }, callbacks.error); + } else { + callbacks.error('Invalid JSEP'); + } + } + + createOffer(handleId, media, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + this.client.log('Creating offer (iceDone=' + config.iceDone + ')'); + + // https://code.google.com/p/webrtc/issues/detail?id=3508 + let mediaConstraints = null; + const browser = adapter.browserDetails.browser; + if (browser === 'firefox' || browser === 'edge') { + mediaConstraints = { + offerToReceiveAudio: this.isAudioRecvEnabled(media), + offerToReceiveVideo: this.isVideoRecvEnabled(media) + }; + } else { + mediaConstraints = { + mandatory: { + OfferToReceiveAudio: this.isAudioRecvEnabled(media), + OfferToReceiveVideo: this.isVideoRecvEnabled(media) + } + }; + } + + this.client.debug(mediaConstraints); + config.pc.createOffer( + (offer) => { + this.client.debug(offer); + + if (!config.mySdp) { + this.client.log('Setting local description'); + config.mySdp = offer.sdp; + config.pc.setLocalDescription(offer); + } + + if (!config.iceDone && !config.trickle) { + // Don't do anything until we have all candidates + this.client.log('Waiting for all candidates...'); + return; + } + + if (config.sdpSent) { + this.client.log('Offer already sent, not sending it again'); + return; + } + + this.client.log('Offer ready'); + this.client.debug(callbacks); + config.sdpSent = true; + + // JSON.stringify doesn't work on some WebRTC objects anymore + // See https://code.google.com/p/chromium/issues/detail?id=467366 + const jsep = { + type: offer.type, + sdp: offer.sdp + }; + callbacks.success(jsep); + }, callbacks.error, mediaConstraints); + } + + createAnswer(handleId, media, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + callbacks.error('Invalid handle'); + return; + } + + const config = pluginHandle.webrtcStuff; + this.client.log('Creating answer (iceDone=' + config.iceDone + ')'); + + let mediaConstraints = null; + const browser = adapter.browserDetails.browser; + if (browser === 'firefox' || browser === 'edge') { + mediaConstraints = { + offerToReceiveAudio: this.isAudioRecvEnabled(media), + offerToReceiveVideo: this.isVideoRecvEnabled(media) + }; + } else { + mediaConstraints = { + mandatory: { + OfferToReceiveAudio: this.isAudioRecvEnabled(media), + OfferToReceiveVideo: this.isVideoRecvEnabled(media) + } + }; + } + this.client.debug(mediaConstraints); + config.pc.createAnswer( + (answer) => { + this.client.debug(answer); + if (!config.mySdp) { + this.client.log('Setting local description'); + config.mySdp = answer.sdp; + config.pc.setLocalDescription(answer); + } + if (!config.iceDone && !config.trickle) { + // Don't do anything until we have all candidates + this.client.log('Waiting for all candidates...'); + return; + } + if (config.sdpSent) { // FIXME badly + this.client.log('Answer already sent, not sending it again'); + return; + } + config.sdpSent = true; + + // JSON.stringify doesn't work on some WebRTC objects anymore + // See https://code.google.com/p/chromium/issues/detail?id=467366 + const jsep = { + type: answer.type, + sdp: answer.sdp + }; + callbacks.success(jsep); + }, callbacks.error, mediaConstraints); + } + + sendSDP(handleId, cbs) { + const callbacks = cbs || {}; + callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop; + callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop; + + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle, not sending anything'); + return; + } + + const config = pluginHandle.webrtcStuff; + this.client.log('Sending offer/answer SDP...'); + if (!config.mySdp) { + this.client.warn('Local SDP instance is invalid, not sending anything...'); + return; + } + + config.mySdp = { + type: config.pc.localDescription.type, + sdp: config.pc.localDescription.sdp + }; + + if (config.sdpSent) { + this.client.log('Offer/Answer SDP already sent, not sending it again'); + return; + } + + if (config.trickle === false) { + config.mySdp.trickle = false; + } + this.client.debug(callbacks); + config.sdpSent = true; + callbacks.success(config.mySdp); + } + + getVolume(handleId) { + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + return 0; + } + + const config = pluginHandle.webrtcStuff; + const browser = adapter.browserDetails.browser; + + // Start getting the volume, if getStats is supported + if (config.pc.getStats && browser === 'chrome') { // FIXME + if (!config.remoteStream) { + this.client.warn('Remote stream unavailable'); + return 0; + } + + // http://webrtc.googlecode.com/svn/trunk/samples/js/demos/html/constraints-and-stats.html + if (!config.volume.timer) { + this.client.log('Starting volume monitor'); + config.volume.timer = setInterval(() => { + config.pc.getStats((stats) => { + const results = stats.result(); + for (let i = 0; i < results.length; i++) { + const res = results[i]; + if (res.type === 'ssrc' && res.stat('audioOutputLevel')) { + config.volume.value = res.stat('audioOutputLevel'); + } + } + }); + }, 200); + return 0; // We don't have a volume to return yet + } + return config.volume.value; + } + + this.client.log('Getting the remote volume unsupported by browser'); + return 0; + } + + isMuted(handleId, video) { + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + return true; + } + + const config = pluginHandle.webrtcStuff; + + if (!config.pc) { + this.client.warn('Invalid PeerConnection'); + return true; + } + + if (!config.myStream) { + this.client.warn('Invalid local MediaStream'); + return true; + } + + if (video) { + // Check video track + if (!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) { + this.client.warn('No video track'); + return true; + } + return !config.myStream.getVideoTracks()[0].enabled; + } + + // Check audio track + if (!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) { + this.client.warn('No audio track'); + return true; + } + return !config.myStream.getAudioTracks()[0].enabled; + } + + mute(handleId, video, mute) { + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + return false; + } + + const config = pluginHandle.webrtcStuff; + if (!config.pc) { + this.client.warn('Invalid PeerConnection'); + return false; + } + + if (!config.myStream) { + this.client.warn('Invalid local MediaStream'); + return false; + } + + if (video) { + // Mute/unmute video track + if (!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) { + this.client.warn('No video track'); + return false; + } + config.myStream.getVideoTracks()[0].enabled = mute; + return true; + } + + // Mute/unmute audio track + if (!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) { + this.client.warn('No audio track'); + return false; + } + config.myStream.getAudioTracks()[0].enabled = mute; + return true; + } + + getBitrate(handleId) { + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle || !pluginHandle.webrtcStuff) { + this.client.warn('Invalid handle'); + return 'Invalid handle'; + } + + const config = pluginHandle.webrtcStuff; + if (!config.pc) { + return 'Invalid PeerConnection'; + } + + // Start getting the bitrate, if getStats is supported + const browser = adapter.browserDetails.browser; + if (config.pc.getStats && browser === 'chrome') { + // Do it the Chrome way + if (!config.remoteStream) { + this.client.warn('Remote stream unavailable'); + return 'Remote stream unavailable'; + } + + // http://webrtc.googlecode.com/svn/trunk/samples/js/demos/html/constraints-and-stats.html + if (!config.bitrate.timer) { + this.client.log('Starting bitrate timer (Chrome)'); + config.bitrate.timer = setInterval(() => { + config.pc.getStats((stats) => { + const results = stats.result(); + for (let i = 0; i < results.length; i++) { + const res = results[i]; + if (res.type === 'ssrc' && res.stat('googFrameHeightReceived')) { + config.bitrate.bsnow = res.stat('bytesReceived'); + config.bitrate.tsnow = res.timestamp; + if (config.bitrate.bsbefore === null || config.bitrate.tsbefore === null) { + // Skip this round + config.bitrate.bsbefore = config.bitrate.bsnow; + config.bitrate.tsbefore = config.bitrate.tsnow; + } else { + // Calculate bitrate + var bitRate = Math.round(((config.bitrate.bsnow - config.bitrate.bsbefore) * 8) / (config.bitrate.tsnow - config.bitrate.tsbefore)); + config.bitrate.value = bitRate + ' kbits/sec'; + + //~ this.client.log('Estimated bitrate is ' + config.bitrate.value); + config.bitrate.bsbefore = config.bitrate.bsnow; + config.bitrate.tsbefore = config.bitrate.tsnow; + } + } + } + }); + }, 1000); + return '0 kbits/sec'; // We don't have a bitrate value yet + } + return config.bitrate.value; + } else if (config.pc.getStats && browser === 'firefox') { + // Do it the Firefox way + if (!config.remoteStream || !config.remoteStream.stream) { + this.client.warn('Remote stream unavailable'); + return 'Remote stream unavailable'; + } + + const videoTracks = config.remoteStream.stream.getVideoTracks(); + if (!videoTracks || videoTracks.length < 1) { + this.client.warn('No video track'); + return 'No video track'; + } + + // https://github.com/muaz-khan/getStats/blob/master/getStats.js + if (!config.bitrate.timer) { + this.client.log('Starting bitrate timer (Firefox)'); + config.bitrate.timer = setInterval(() => { + // We need a helper callback + function cb(res) { + if (!res || res.inbound_rtp_video_1 == null || res.inbound_rtp_video_1 == null) { + config.bitrate.value = 'Missing inbound_rtp_video_1'; + return; + } + + config.bitrate.bsnow = res.inbound_rtp_video_1.bytesReceived; + config.bitrate.tsnow = res.inbound_rtp_video_1.timestamp; + + if (config.bitrate.bsbefore === null || config.bitrate.tsbefore === null) { + // Skip this round + config.bitrate.bsbefore = config.bitrate.bsnow; + config.bitrate.tsbefore = config.bitrate.tsnow; + } else { + // Calculate bitrate + var bitRate = Math.round(((config.bitrate.bsnow - config.bitrate.bsbefore) * 8) / (config.bitrate.tsnow - config.bitrate.tsbefore)); + config.bitrate.value = bitRate + ' kbits/sec'; + config.bitrate.bsbefore = config.bitrate.bsnow; + config.bitrate.tsbefore = config.bitrate.tsnow; + } + } + + // Actually get the stats + config.pc.getStats(videoTracks[0], (stats) => { + cb(stats); + }, cb); + }, 1000); + return '0 kbits/sec'; // We don't have a bitrate value yet + } + return config.bitrate.value; + } + + this.client.warn('Getting the video bitrate unsupported by browser'); + return 'Feature unsupported by browser'; + } + + webrtcError(error) { + this.client.error('WebRTC error:', error); + } + + cleanupWebrtc(handleId, hangupRequest) { + this.client.log('Cleaning WebRTC stuff'); + const pluginHandle = this.pluginHandles[handleId]; + if (!pluginHandle) { + // Nothing to clean + return; + } + + const config = pluginHandle.webrtcStuff; + if (config) { + if (hangupRequest === true) { + // Send a hangup request (we don't really care about the response) + const request = { + janus: 'hangup', + transaction: WebrtcSession.randomString(transationLength) + }; + + if (this.token) { + request.token = this.token; + } + + if (this.apisecret) { + request.apisecret = this.apisecret; + } + + this.client.debug('Sending hangup request (handle=' + handleId + '):'); + this.client.debug(request); + if (this.websockets) { + request.session_id = this.sessionId; + request.handle_id = handleId; + this.ws.send(JSON.stringify(request)); + } + } + + // Cleanup stack + config.remoteStream = null; + if (config.volume.timer) { + clearInterval(config.volume.timer); + } + + config.volume.value = null; + if (config.bitrate.timer) { + clearInterval(config.bitrate.timer); + } + + config.bitrate.timer = null; + config.bitrate.bsnow = null; + config.bitrate.bsbefore = null; + config.bitrate.tsnow = null; + config.bitrate.tsbefore = null; + config.bitrate.value = null; + + try { + // Try a MediaStream.stop() first + if (!config.streamExternal && config.myStream) { + this.client.log('Stopping local stream'); + config.myStream.stop(); + } + } catch (e) { + // Do nothing if this fails + } + + try { + // Try a MediaStreamTrack.stop() for each track as well + if (!config.streamExternal && config.myStream) { + this.client.log('Stopping local stream tracks'); + WebrtcSession.stopMediaStream(config.myStream); + } + } catch (e) { + // Do nothing if this fails + } + + config.streamExternal = false; + config.myStream = null; + + // Close PeerConnection + try { + config.pc.close(); + } catch (e) { + // Do nothing + } + config.pc = null; + config.mySdp = null; + config.iceDone = false; + config.sdpSent = false; + config.dataChannel = null; + config.dtmfSender = null; + } + pluginHandle.oncleanup(); + } + + isAudioSendEnabled(media) { + this.client.debug('isAudioSendEnabled:', media); + if (!media) { + return true; // Default + } + + if (media.audio === false) { + return false; // Generic audio has precedence + } + + if (!media.audioSend) { + return true; // Default + } + + return (media.audioSend === true); + } + + isAudioRecvEnabled(media) { + this.client.debug('isAudioRecvEnabled:', media); + if (!media) { + return true; // Default + } + + if (media.audio === false) { + return false; // Generic audio has precedence + } + + if (!media.audioRecv) { + return true; // Default + } + + return (media.audioRecv === true); + } + + isVideoSendEnabled(media) { + this.client.debug('isVideoSendEnabled:', media); + const browser = adapter.browserDetails.browser; + if (browser === 'edge') { + this.client.warn("Edge doesn't support compatible video yet"); + return false; + } + + if (!media) { + return true; // Default + } + + if (media.video === false) { + return false; // Generic video has precedence + } + + if (!media.videoSend) { + return true; // Default + } + + return (media.videoSend === true); + } + + isVideoRecvEnabled(media) { + this.client.debug('isVideoRecvEnabled:', media); + const browser = adapter.browserDetails.browser; + if (browser === 'edge') { + this.client.warn("Edge doesn't support compatible video yet"); + return false; + } + + if (!media) { + return true; // Default + } + + if (media.video === false) { + return false; // Generic video has precedence + } + + if (!media.videoRecv) { + return true; // Default + } + + return (media.videoRecv === true); + } + + isDataEnabled(media) { + this.client.debug('isDataEnabled:', media); + const browser = adapter.browserDetails.browser; + if (browser === 'edge') { + this.client.warn("Edge doesn't support data channels yet"); + return false; + } + + if (!media) { + return false; // Default + } + + return (media.data === true); + } + + isTrickleEnabled(trickle) { + this.client.debug('isTrickleEnabled:', trickle); + if (!trickle) { + return true; // Default is true + } + + return (trickle === true); + } + + unbindWebSocket = (onUnbindMessage, onUnbindError) => { + for (var eventName in this.wsHandlers) { + if (this.wsHandlers.hasOwnProperty(eventName)) { + this.ws.removeEventListener(eventName, this.wsHandlers[eventName]); + } + } + this.ws.removeEventListener('message', onUnbindMessage); + this.ws.removeEventListener('error', onUnbindError); + if (this.wsKeepaliveTimeoutId) { + clearTimeout(this.wsKeepaliveTimeoutId); + } + }; +} \ No newline at end of file -- cgit v1.2.3-1-g7c22