/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* global loop:true */ var loop = loop || {}; loop.OTSdkDriver = (function() { var sharedActions = loop.shared.actions; var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS; var STREAM_PROPERTIES = loop.shared.utils.STREAM_PROPERTIES; var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES; /** * This is a wrapper for the OT sdk. It is used to translate the SDK events into * actions, and instruct the SDK what to do as a result of actions. */ var OTSdkDriver = function(options) { if (!options.dispatcher) { throw new Error("Missing option dispatcher"); } if (!options.sdk) { throw new Error("Missing option sdk"); } this.dispatcher = options.dispatcher; this.sdk = options.sdk; this._isDesktop = !!options.isDesktop; if (this._isDesktop) { if (!options.mozLoop) { throw new Error("Missing option mozLoop"); } this.mozLoop = options.mozLoop; } this.connections = {}; // Metrics object to keep track of the number of connections we have // and the amount of streams. this._metrics = { connections: 0, sendStreams: 0, recvStreams: 0 }; this.dispatcher.register(this, [ "setupStreamElements", "setMute" ]); // Set loop.debug.twoWayMediaTelemetry to true in the browser // by changing the hidden pref loop.debug.twoWayMediaTelemetry using // about:config, or use // // localStorage.setItem("debug.twoWayMediaTelemetry", true); this._debugTwoWayMediaTelemetry = loop.shared.utils.getBoolPreference("debug.twoWayMediaTelemetry"); /** * XXX This is a workaround for desktop machines that do not have a * camera installed. As we don't yet have device enumeration, when * we do, this can be removed (bug 1138851), and the sdk should handle it. */ if (this._isDesktop && !window.MediaStreamTrack.getSources) { // If there's no getSources function, the sdk defines its own and caches // the result. So here we define the "normal" one which doesn't get cached, so // we can change it later. window.MediaStreamTrack.getSources = function(callback) { callback([{kind: "audio"}, {kind: "video"}]); }; } }; OTSdkDriver.prototype = { /** * Clones the publisher config into a new object, as the sdk modifies the * properties object. */ _getCopyPublisherConfig: function() { return _.extend({}, this.publisherConfig); }, /** * Handles the setupStreamElements action. Saves the required data and * kicks off the initialising of the publisher. * * @param {sharedActions.SetupStreamElements} actionData The data associated * with the action. See action.js. */ setupStreamElements: function(actionData) { this.getLocalElement = actionData.getLocalElementFunc; this.getScreenShareElementFunc = actionData.getScreenShareElementFunc; this.getRemoteElement = actionData.getRemoteElementFunc; this.publisherConfig = actionData.publisherConfig; this.sdk.on("exception", this._onOTException.bind(this)); // At this state we init the publisher, even though we might be waiting for // the initial connect of the session. This saves time when setting up // the media. this._publishLocalStreams(); }, /** * Internal function to publish a local stream. * XXX This can be simplified when bug 1138851 is actioned. */ _publishLocalStreams: function() { this.publisher = this.sdk.initPublisher(this.getLocalElement(), this._getCopyPublisherConfig()); this.publisher.on("streamCreated", this._onLocalStreamCreated.bind(this)); this.publisher.on("streamDestroyed", this._onLocalStreamDestroyed.bind(this)); this.publisher.on("accessAllowed", this._onPublishComplete.bind(this)); this.publisher.on("accessDenied", this._onPublishDenied.bind(this)); this.publisher.on("accessDialogOpened", this._onAccessDialogOpened.bind(this)); }, /** * Forces the sdk into not using video, and starts publishing again. * XXX This is part of the work around that will be removed by bug 1138851. */ retryPublishWithoutVideo: function() { window.MediaStreamTrack.getSources = function(callback) { callback([{kind: "audio"}]); }; this._publishLocalStreams(); }, /** * Handles the setMute action. Informs the published stream to mute * or unmute audio as appropriate. * * @param {sharedActions.SetMute} actionData The data associated with the * action. See action.js. */ setMute: function(actionData) { if (actionData.type === "audio") { this.publisher.publishAudio(actionData.enabled); } else { this.publisher.publishVideo(actionData.enabled); } }, /** * Initiates a screen sharing publisher. * * options items: * - {String} videoSource The type of screen to share. Values of 'screen', * 'window', 'application' and 'browser' are * currently supported. * - {mixed} browserWindow The unique identifier of a browser window. May * be passed when `videoSource` is 'browser'. * - {Boolean} scrollWithPage Flag to signal that scrolling a page should * update the stream. May be passed when * `videoSource` is 'browser'. * * @param {Object} options Hash containing options for the SDK */ startScreenShare: function(options) { // For browser sharing, we store the window Id so that we can avoid unnecessary // re-triggers. if (options.videoSource === "browser") { this._windowId = options.constraints.browserWindow; } var config = _.extend(this._getCopyPublisherConfig(), options); this.screenshare = this.sdk.initPublisher(this.getScreenShareElementFunc(), config); this.screenshare.on("accessAllowed", this._onScreenShareGranted.bind(this)); this.screenshare.on("accessDenied", this._onScreenShareDenied.bind(this)); this.screenshare.on("streamCreated", this._onScreenShareStreamCreated.bind(this)); this._noteSharingState(options.videoSource, true); }, /** * Initiates switching the browser window that is being shared. * * @param {Integer} windowId The windowId of the browser. */ switchAcquiredWindow: function(windowId) { if (windowId === this._windowId) { return; } this._windowId = windowId; this.screenshare._.switchAcquiredWindow(windowId); }, /** * Ends an active screenshare session. Return `true` when an active screen- * sharing session was ended or `false` when no session is active. * * @type {Boolean} */ endScreenShare: function() { if (!this.screenshare) { return false; } this._notifyMetricsEvent("Publisher.streamDestroyed"); this.session.unpublish(this.screenshare); this.screenshare.off("accessAllowed accessDenied streamCreated"); this.screenshare.destroy(); delete this.screenshare; this._noteSharingState(this._windowId ? "browser" : "window", false); delete this._windowId; return true; }, /** * Connects a session for the SDK, listening to the required events. * * sessionData items: * - sessionId: The OT session ID * - apiKey: The OT API key * - sessionToken: The token for the OT session * - sendTwoWayMediaTelemetry: boolean should we send telemetry on length * of media sessions. Callers should ensure * that this is only set for one side of the * session so that things don't get * double-counted. * * @param {Object} sessionData The session data for setting up the OT session. */ connectSession: function(sessionData) { this.session = this.sdk.initSession(sessionData.sessionId); this._sendTwoWayMediaTelemetry = !!sessionData.sendTwoWayMediaTelemetry; this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED); this.session.on("sessionDisconnected", this._onSessionDisconnected.bind(this)); this.session.on("connectionCreated", this._onConnectionCreated.bind(this)); this.session.on("connectionDestroyed", this._onConnectionDestroyed.bind(this)); this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this)); this.session.on("streamDestroyed", this._onRemoteStreamDestroyed.bind(this)); this.session.on("streamPropertyChanged", this._onStreamPropertyChanged.bind(this)); // This starts the actual session connection. this.session.connect(sessionData.apiKey, sessionData.sessionToken, this._onSessionConnectionCompleted.bind(this)); }, /** * Disconnects the sdk session. */ disconnectSession: function() { this.endScreenShare(); if (this.session) { this.session.off("sessionDisconnected streamCreated streamDestroyed connectionCreated connectionDestroyed streamPropertyChanged"); this.session.disconnect(); delete this.session; this._notifyMetricsEvent("Session.connectionDestroyed", "local"); } if (this.publisher) { this.publisher.off("accessAllowed accessDenied accessDialogOpened streamCreated"); this.publisher.destroy(); delete this.publisher; } this._noteConnectionLengthIfNeeded(this._getTwoWayMediaStartTime(), performance.now()); // Also, tidy these variables ready for next time. delete this._sessionConnected; delete this._publisherReady; delete this._publishedLocalStream; delete this._subscribedRemoteStream; this.connections = {}; this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED); }, /** * Oust all users from an ongoing session. This is typically done when a room * owner deletes the room. * * @param {Function} callback Function to be invoked once all connections are * ousted */ forceDisconnectAll: function(callback) { if (!this._sessionConnected) { callback(); return; } var connectionNames = Object.keys(this.connections); if (connectionNames.length === 0) { callback(); return; } var disconnectCount = 0; connectionNames.forEach(function(id) { var connection = this.connections[id]; this.session.forceDisconnect(connection, function() { // When all connections have disconnected, call the callback, since // we're done. if (++disconnectCount === connectionNames.length) { callback(); } }); }, this); }, /** * Called once the session has finished connecting. * * @param {Error} error An OT error object, null if there was no error. */ _onSessionConnectionCompleted: function(error) { if (error) { console.error("Failed to complete connection", error); this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: FAILURE_DETAILS.COULD_NOT_CONNECT })); return; } this.dispatcher.dispatch(new sharedActions.ConnectedToSdkServers()); this._sessionConnected = true; this._maybePublishLocalStream(); }, /** * Handles the connection event for a peer's connection being dropped. * * @param {ConnectionEvent} event The event details * https://tokbox.com/opentok/libraries/client/js/reference/ConnectionEvent.html */ _onConnectionDestroyed: function(event) { var connection = event.connection; if (connection && (connection.id in this.connections)) { delete this.connections[connection.id]; } this._notifyMetricsEvent("Session.connectionDestroyed", "peer"); this._noteConnectionLengthIfNeeded(this._getTwoWayMediaStartTime(), performance.now()); this.dispatcher.dispatch(new sharedActions.RemotePeerDisconnected({ peerHungup: event.reason === "clientDisconnected" })); }, /** * Handles the session event for the connection for this client being * destroyed. * * @param {SessionDisconnectEvent} event The event details: * https://tokbox.com/opentok/libraries/client/js/reference/SessionDisconnectEvent.html */ _onSessionDisconnected: function(event) { var reason; switch (event.reason) { case "networkDisconnected": reason = FAILURE_DETAILS.NETWORK_DISCONNECTED; break; case "forceDisconnected": reason = FAILURE_DETAILS.EXPIRED_OR_INVALID; break; default: // Other cases don't need to be handled. return; } this._noteConnectionLengthIfNeeded(this._getTwoWayMediaStartTime(), performance.now()); this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: reason })); }, /** * Handles the connection event for a newly connecting peer. * * @param {ConnectionEvent} event The event details * https://tokbox.com/opentok/libraries/client/js/reference/ConnectionEvent.html */ _onConnectionCreated: function(event) { var connection = event.connection; if (this.session.connection.id === connection.id) { // This is the connection event for our connection. this._notifyMetricsEvent("Session.connectionCreated", "local"); return; } this.connections[connection.id] = connection; this._notifyMetricsEvent("Session.connectionCreated", "peer"); this.dispatcher.dispatch(new sharedActions.RemotePeerConnected()); }, /** * Works out the current connection state based on the streams being * sent or received. */ _getConnectionState: function() { if (this._metrics.sendStreams) { return this._metrics.recvStreams ? "sendrecv" : "sending"; } if (this._metrics.recvStreams) { return "receiving"; } return "starting"; }, /** * Notifies of a metrics related event for tracking call setup purposes. * See https://wiki.mozilla.org/Loop/Architecture/Rooms#Updating_Session_State * * @param {String} eventName The name of the event for the update. * @param {String} clientType Used for connection created/destoryed. Indicates * if it is for the "peer" or the "local" client. */ _notifyMetricsEvent: function(eventName, clientType) { if (!eventName) { return; } var state; // We intentionally don't bounds check these, in case there's an error // somewhere, if there is, we'll see it in the server metrics and are more // likely to investigate. switch (eventName) { case "Session.connectionCreated": this._metrics.connections++; if (clientType === "local") { state = "waiting"; } break; case "Session.connectionDestroyed": this._metrics.connections--; if (clientType === "local") { // Don't log this, as the server doesn't accept it after // the room has been left. return; } else if (!this._metrics.connections) { state = "waiting"; } break; case "Publisher.streamCreated": this._metrics.sendStreams++; break; case "Publisher.streamDestroyed": this._metrics.sendStreams--; break; case "Session.streamCreated": this._metrics.recvStreams++; break; case "Session.streamDestroyed": this._metrics.recvStreams--; break; default: console.error("Unexpected event name", eventName); return; } if (!state) { state = this._getConnectionState(); } this.dispatcher.dispatch(new sharedActions.ConnectionStatus({ event: eventName, state: state, connections: this._metrics.connections, sendStreams: this._metrics.sendStreams, recvStreams: this._metrics.recvStreams })); }, /** * Handles when a remote screen share is created, subscribing to * the stream, and notifying the stores that a share is being * received. * * @param {Stream} stream The SDK Stream: * https://tokbox.com/opentok/libraries/client/js/reference/Stream.html */ _handleRemoteScreenShareCreated: function(stream) { if (!this.getScreenShareElementFunc) { return; } // Let the stores know first so they can update the display. this.dispatcher.dispatch(new sharedActions.ReceivingScreenShare({ receiving: true })); var remoteElement = this.getScreenShareElementFunc(); this.session.subscribe(stream, remoteElement, this._getCopyPublisherConfig()); }, /** * Handles the event when the remote stream is created. * * @param {StreamEvent} event The event details: * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html */ _onRemoteStreamCreated: function(event) { this._notifyMetricsEvent("Session.streamCreated"); if (event.stream[STREAM_PROPERTIES.HAS_VIDEO]) { this.dispatcher.dispatch(new sharedActions.VideoDimensionsChanged({ isLocal: false, videoType: event.stream.videoType, dimensions: event.stream[STREAM_PROPERTIES.VIDEO_DIMENSIONS] })); } if (event.stream.videoType === "screen") { this._handleRemoteScreenShareCreated(event.stream); return; } var remoteElement = this.getRemoteElement(); this.session.subscribe(event.stream, remoteElement, this._getCopyPublisherConfig()); this._subscribedRemoteStream = true; if (this._checkAllStreamsConnected()) { this._setTwoWayMediaStartTime(performance.now()); this.dispatcher.dispatch(new sharedActions.MediaConnected()); } }, /** * Handles the event when the local stream is created. * * @param {StreamEvent} event The event details: * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html */ _onLocalStreamCreated: function(event) { this._notifyMetricsEvent("Publisher.streamCreated"); if (event.stream[STREAM_PROPERTIES.HAS_VIDEO]) { this.dispatcher.dispatch(new sharedActions.VideoDimensionsChanged({ isLocal: true, videoType: event.stream.videoType, dimensions: event.stream[STREAM_PROPERTIES.VIDEO_DIMENSIONS] })); } }, /** * Implementation detail, may be set to one of the CONNECTION_START_TIME * constants, or a positive integer in milliseconds. * * @private */ __twoWayMediaStartTime: undefined, /** * Used as a guard to make sure we don't inadvertently use an * uninitialized value. */ CONNECTION_START_TIME_UNINITIALIZED: -1, /** * Use as a guard to ensure that we don't note any bidirectional sessions * twice. */ CONNECTION_START_TIME_ALREADY_NOTED: -2, /** * Set and get the start time of the two-way media connection. These * are done as wrapper functions so that we can log sets to make manual * verification of various telemetry scenarios possible. The get API is * analogous in order to follow the principle of least surprise for * people consuming this code. * * If this._sendTwoWayMediaTelemetry is not true, returns immediately * without making any changes, since this data is not used, and it makes * reading the logs confusing for manual verification of both ends of the * call in the same browser, which is a case we care about. * * @param start start time in milliseconds, as returned by * performance.now() * @private */ _setTwoWayMediaStartTime: function(start) { if (!this._sendTwoWayMediaTelemetry) { return; } this.__twoWayMediaStartTime = start; if (this._debugTwoWayMediaTelemetry) { console.log("Loop Telemetry: noted two-way connection start, " + "start time in ms:", start); } }, _getTwoWayMediaStartTime: function() { return this.__twoWayMediaStartTime; }, /** * Handles the event when the remote stream is destroyed. * * @param {StreamEvent} event The event details: * https://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html */ _onRemoteStreamDestroyed: function(event) { this._notifyMetricsEvent("Session.streamDestroyed"); if (event.stream.videoType !== "screen") { return; } // All we need to do is notify the store we're no longer receiving, // the sdk should do the rest. this.dispatcher.dispatch(new sharedActions.ReceivingScreenShare({ receiving: false })); }, /** * Handles the event when the remote stream is destroyed. */ _onLocalStreamDestroyed: function() { this._notifyMetricsEvent("Publisher.streamDestroyed"); }, /** * Called from the sdk when the media access dialog is opened. * Prevents the default action, to prevent the SDK's "allow access" * dialog from being shown. * * @param {OT.Event} event */ _onAccessDialogOpened: function(event) { event.preventDefault(); }, /** * Handles the publishing being complete. * * @param {OT.Event} event */ _onPublishComplete: function(event) { event.preventDefault(); this._publisherReady = true; this.dispatcher.dispatch(new sharedActions.GotMediaPermission()); this._maybePublishLocalStream(); }, /** * Handles publishing of media being denied. * * @param {OT.Event} event */ _onPublishDenied: function(event) { // This prevents the SDK's "access denied" dialog showing. event.preventDefault(); this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: FAILURE_DETAILS.MEDIA_DENIED })); }, _onOTException: function(event) { if (event.code === OT.ExceptionCodes.UNABLE_TO_PUBLISH && event.message === "GetUserMedia") { // We free up the publisher here in case the store wants to try // grabbing the media again. if (this.publisher) { this.publisher.off("accessAllowed accessDenied accessDialogOpened streamCreated"); this.publisher.destroy(); delete this.publisher; } this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ reason: FAILURE_DETAILS.UNABLE_TO_PUBLISH_MEDIA })); } }, /** * Handles publishing of property changes to a stream. */ _onStreamPropertyChanged: function(event) { if (event.changedProperty == STREAM_PROPERTIES.VIDEO_DIMENSIONS) { this.dispatcher.dispatch(new sharedActions.VideoDimensionsChanged({ isLocal: event.stream.connection.id == this.session.connection.id, videoType: event.stream.videoType, dimensions: event.stream[STREAM_PROPERTIES.VIDEO_DIMENSIONS] })); } }, /** * Publishes the local stream if the session is connected * and the publisher is ready. */ _maybePublishLocalStream: function() { if (this._sessionConnected && this._publisherReady) { // We are clear to publish the stream to the session. this.session.publish(this.publisher); // Now record the fact, and check if we've got all media yet. this._publishedLocalStream = true; if (this._checkAllStreamsConnected()) { this._setTwoWayMediaStartTime(performance.now()); this.dispatcher.dispatch(new sharedActions.MediaConnected()); } } }, /** * Used to check if both local and remote streams are available * and send an action if they are. */ _checkAllStreamsConnected: function() { return this._publishedLocalStream && this._subscribedRemoteStream; }, /** * Called when a screenshare is complete, publishes it to the session. */ _onScreenShareGranted: function() { this.session.publish(this.screenshare); this.dispatcher.dispatch(new sharedActions.ScreenSharingState({ state: SCREEN_SHARE_STATES.ACTIVE })); }, /** * Called when a screenshare is denied. Notifies the other stores. */ _onScreenShareDenied: function() { this.dispatcher.dispatch(new sharedActions.ScreenSharingState({ state: SCREEN_SHARE_STATES.INACTIVE })); }, /** * Called when a screenshare stream is published. */ _onScreenShareStreamCreated: function() { this._notifyMetricsEvent("Publisher.streamCreated"); }, /* * XXX all of the bi-directional media connection telemetry stuff in this * file, (much, but not all, of it is below) should be hoisted into its * own object for maintainability and clarity, also in part because this * stuff only wants to run one side of the connection, not both (tracked * by bug 1145237). */ /** * A hook exposed only for the use of the functional tests so that * they can check that the bi-directional media count is being updated * correctly. * * @type number * @private */ _connectionLengthNotedCalls: 0, /** * Wrapper for adding a keyed value that also updates * connectionLengthNoted calls and sets the twoWayMediaStartTime to * this.CONNECTION_START_TIME_ALREADY_NOTED. * * @param {number} callLengthSeconds the call length in seconds * @private */ _noteConnectionLength: function(callLengthSeconds) { var buckets = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH; var bucket = buckets.SHORTER_THAN_10S; if (callLengthSeconds >= 10 && callLengthSeconds <= 30) { bucket = buckets.BETWEEN_10S_AND_30S; } else if (callLengthSeconds > 30 && callLengthSeconds <= 300) { bucket = buckets.BETWEEN_30S_AND_5M; } else if (callLengthSeconds > 300) { bucket = buckets.MORE_THAN_5M; } this.mozLoop.telemetryAddValue("LOOP_TWO_WAY_MEDIA_CONN_LENGTH_1", bucket); this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_ALREADY_NOTED); this._connectionLengthNotedCalls++; if (this._debugTwoWayMediaTelemetry) { console.log('Loop Telemetry: noted two-way media connection ' + 'in bucket: ', bucket); } }, /** * Note connection length if it's valid (the startTime has been initialized * and is not later than endTime) and not yet already noted. If * this._sendTwoWayMediaTelemetry is not true, we return immediately. * * @param {number} startTime in milliseconds * @param {number} endTime in milliseconds * @private */ _noteConnectionLengthIfNeeded: function(startTime, endTime) { if (!this._sendTwoWayMediaTelemetry) { return; } if (startTime == this.CONNECTION_START_TIME_ALREADY_NOTED || startTime == this.CONNECTION_START_TIME_UNINITIALIZED || startTime > endTime) { if (this._debugTwoWayMediaTelemetry) { console.log("_noteConnectionLengthIfNeeded called with " + " invalid params, either the calls were never" + " connected or there is a bug; startTime:", startTime, "endTime:", endTime); } return; } var callLengthSeconds = (endTime - startTime) / 1000; this._noteConnectionLength(callLengthSeconds); }, /** * If set to true, make it easy to test/verify 2-way media connection * telemetry code operation by viewing the logs. */ _debugTwoWayMediaTelemetry: false, /** * Note the sharing state. If this.mozLoop is not defined, we're assumed to * be running in the standalone client and return immediately. * * @param {String} type Type of sharing that was flipped. May be 'window' * or 'tab'. * @param {Boolean} enabled Flag that tells us if the feature was flipped on * or off. * @private */ _noteSharingState: function(type, enabled) { if (!this.mozLoop) { return; } var bucket = this.mozLoop.SHARING_STATE_CHANGE[type.toUpperCase() + "_" + (enabled ? "ENABLED" : "DISABLED")]; if (!bucket) { console.error("No sharing state bucket found for '" + type + "'"); return; } this.mozLoop.telemetryAddValue("LOOP_SHARING_STATE_CHANGE_1", bucket); } }; return OTSdkDriver; })();