Bug 1203529 - Bad display when starting an audio-only call to a contact. r=dmose,a=sledru
authorMark Banner <standard8@mozilla.com>
Fri, 11 Sep 2015 07:53:51 +0100
changeset 289310 2d896fd0ad024370e55e1a48075be5fedbc9340a
parent 289309 8a02ad48b05f6f9af00b73d21305380860f40b7c
child 289311 fbb02d5e678489003d9587410d807bfe3e1158fa
push id5067
push userraliiev@mozilla.com
push dateMon, 21 Sep 2015 14:04:52 +0000
treeherdermozilla-beta@14221ffe5b2f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdmose, sledru
bugs1203529
milestone42.0a2
Bug 1203529 - Bad display when starting an audio-only call to a contact. r=dmose,a=sledru
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/shared/conversationStore_test.js
browser/components/loop/test/shared/otSdkDriver_test.js
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -218,49 +218,41 @@ loop.shared.actions = (function() {
      */
     VideoDimensionsChanged: Action.define("videoDimensionsChanged", {
       isLocal: Boolean,
       videoType: String,
       dimensions: Object
     }),
 
     /**
-     * Video has been enabled from the remote sender.
-     *
-     * XXX somewhat tangled up with remote video muting semantics; see bug
-     * 1171969
-     *
-     * @note if/when we want to untangle this, we'll may want to include the
-     *       reason provided by the SDK and documented hereI:
-     *       https://tokbox.com/opentok/libraries/client/js/reference/VideoEnabledChangedEvent.html
+     * A stream from local or remote media has been created.
      */
-    RemoteVideoEnabled: Action.define("remoteVideoEnabled", {
-      /* The SDK video object that the views will be copying the remote
-         stream from. */
+    MediaStreamCreated: Action.define("mediaStreamCreated", {
+      hasVideo: Boolean,
+      isLocal: Boolean,
       srcVideoObject: Object
     }),
 
     /**
-     * Video has been disabled by the remote sender.
-     *
-     *  @see RemoteVideoEnabled
+     * A stream from local or remote media has been destroyed.
      */
-    RemoteVideoDisabled: Action.define("remoteVideoDisabled", {
+    MediaStreamDestroyed: Action.define("mediaStreamDestroyed", {
+      isLocal: Boolean
     }),
 
     /**
-     * Video from the local camera has been enabled.
+     * Used to inform that the remote stream has enabled or disabled the video
+     * part of the stream.
      *
-     * XXX we should implement a LocalVideoDisabled action to cleanly prevent
-     * leakage; see bug 1171978 for details
+     * @note We'll may want to include the future the reason provided by the SDK
+     *       and documented here:
+     *       https://tokbox.com/opentok/libraries/client/js/reference/VideoEnabledChangedEvent.html
      */
-    LocalVideoEnabled: Action.define("localVideoEnabled", {
-      /* The SDK video object that the views will be copying the remote
-         stream from. */
-      srcVideoObject: Object
+    RemoteVideoStatus: Action.define("remoteVideoStatus", {
+      videoEnabled: Boolean
     }),
 
     /**
      * Used to mute or unmute a stream
      */
     SetMute: Action.define("setMute", {
       // The part of the stream to enable, e.g. "audio" or "video"
       type: String,
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -219,19 +219,19 @@ loop.store.ActiveRoomStore = (function()
         "setMute",
         "screenSharingState",
         "receivingScreenShare",
         "remotePeerDisconnected",
         "remotePeerConnected",
         "windowUnload",
         "leaveRoom",
         "feedbackComplete",
-        "localVideoEnabled",
-        "remoteVideoEnabled",
-        "remoteVideoDisabled",
+        "mediaStreamCreated",
+        "mediaStreamDestroyed",
+        "remoteVideoStatus",
         "videoDimensionsChanged",
         "startScreenShare",
         "endScreenShare",
         "updateSocialShareInfo",
         "connectionStatus",
         "mediaConnected"
       ]);
     },
@@ -601,41 +601,62 @@ loop.store.ActiveRoomStore = (function()
      */
     setMute: function(actionData) {
       var muteState = {};
       muteState[actionData.type + "Muted"] = !actionData.enabled;
       this.setStoreState(muteState);
     },
 
     /**
-     * Records the local video object for the room.
+     * Handles a media stream being created. This may be a local or a remote stream.
      *
-     * @param {sharedActions.LocalVideoEnabled} actionData
+     * @param {sharedActions.MediaStreamCreated} actionData
      */
-    localVideoEnabled: function(actionData) {
-      this.setStoreState({localSrcVideoObject: actionData.srcVideoObject});
-    },
+    mediaStreamCreated: function(actionData) {
+      if (actionData.isLocal) {
+        this.setStoreState({
+          localVideoEnabled: actionData.hasVideo,
+          localSrcVideoObject: actionData.srcVideoObject
+        });
+        return;
+      }
 
-    /**
-     * Records the remote video object for the room.
-     *
-     * @param  {sharedActions.RemoteVideoEnabled} actionData
-     */
-    remoteVideoEnabled: function(actionData) {
       this.setStoreState({
-        remoteVideoEnabled: true,
+        remoteVideoEnabled: actionData.hasVideo,
         remoteSrcVideoObject: actionData.srcVideoObject
       });
     },
 
     /**
-     * Records when remote video is disabled (e.g. due to mute).
+     * Handles a media stream being destroyed. This may be a local or a remote stream.
+     *
+     * @param {sharedActions.MediaStreamDestroyed} actionData
      */
-    remoteVideoDisabled: function() {
-      this.setStoreState({remoteVideoEnabled: false});
+    mediaStreamDestroyed: function(actionData) {
+      if (actionData.isLocal) {
+        this.setStoreState({
+          localSrcVideoObject: null
+        });
+        return;
+      }
+
+      this.setStoreState({
+        remoteSrcVideoObject: null
+      });
+    },
+
+    /**
+     * Handles a remote stream having video enabled or disabled.
+     *
+     * @param {sharedActions.RemoteVideoStatus} actionData
+     */
+    remoteVideoStatus: function(actionData) {
+      this.setStoreState({
+        remoteVideoEnabled: actionData.videoEnabled
+      });
     },
 
     /**
      * Records when the remote media has been connected.
      */
     mediaConnected: function() {
       this.setStoreState({mediaConnected: true});
     },
@@ -766,16 +787,17 @@ loop.store.ActiveRoomStore = (function()
 
     /**
      * Handles a remote peer disconnecting from the session. As we currently only
      * support 2 participants, we declare the room as SESSION_CONNECTED as soon as
      * one participantleaves.
      */
     remotePeerDisconnected: function() {
       this.setStoreState({
+        mediaConnected: false,
         roomState: ROOM_STATES.SESSION_CONNECTED,
         remoteSrcVideoObject: null
       });
     },
 
     /**
      * Handles an SDK status update, forwarding it to the server.
      *
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -217,19 +217,19 @@ loop.store = loop.store || {};
         "connectCall",
         "hangupCall",
         "remotePeerDisconnected",
         "cancelCall",
         "retryCall",
         "mediaConnected",
         "setMute",
         "fetchRoomEmailLink",
-        "localVideoEnabled",
-        "remoteVideoDisabled",
-        "remoteVideoEnabled",
+        "mediaStreamCreated",
+        "mediaStreamDestroyed",
+        "remoteVideoStatus",
         "windowUnload"
       ]);
 
       this.setStoreState({
         apiKey: actionData.apiKey,
         callerId: actionData.callerId,
         callId: actionData.callId,
         callState: CALL_STATES.GATHER,
@@ -435,50 +435,61 @@ loop.store = loop.store || {};
           return;
         }
         this.setStoreState({"emailLink": createdRoomData.roomUrl});
         this.mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_SUCCESS);
       }.bind(this));
     },
 
     /**
-     * Handles when the remote stream has been enabled and is supplied.
+     * Handles a media stream being created. This may be a local or a remote stream.
      *
-     * @param  {sharedActions.RemoteVideoEnabled} actionData
+     * @param {sharedActions.MediaStreamCreated} actionData
      */
-    remoteVideoEnabled: function(actionData) {
+    mediaStreamCreated: function(actionData) {
+      if (actionData.isLocal) {
+        this.setStoreState({
+          localVideoEnabled: actionData.hasVideo,
+          localSrcVideoObject: actionData.srcVideoObject
+        });
+        return;
+      }
+
       this.setStoreState({
-        remoteVideoEnabled: true,
+        remoteVideoEnabled: actionData.hasVideo,
         remoteSrcVideoObject: actionData.srcVideoObject
       });
     },
 
     /**
-     * Handles when the remote stream has been disabled, e.g. due to video mute.
+     * Handles a media stream being destroyed. This may be a local or a remote stream.
      *
-     * @param {sharedActions.RemoteVideoDisabled} actionData
+     * @param {sharedActions.MediaStreamDestroyed} actionData
      */
-    remoteVideoDisabled: function(actionData) {
+    mediaStreamDestroyed: function(actionData) {
+      if (actionData.isLocal) {
+        this.setStoreState({
+          localSrcVideoObject: null
+        });
+        return;
+      }
+
       this.setStoreState({
-        remoteVideoEnabled: false,
-        remoteSrcVideoObject: undefined});
+        remoteSrcVideoObject: null
+      });
     },
 
     /**
-     * Handles when the local stream is supplied.
-     *
-     * XXX should write a localVideoDisabled action in otSdkDriver.js to
-     * positively ensure proper cleanup (handled by window teardown currently)
-     * (see bug 1171978)
+     * Handles a remote stream having video enabled or disabled.
      *
-     * @param  {sharedActions.LocalVideoEnabled} actionData
+     * @param {sharedActions.RemoteVideoStatus} actionData
      */
-    localVideoEnabled: function(actionData) {
+    remoteVideoStatus: function(actionData) {
       this.setStoreState({
-        localSrcVideoObject: actionData.srcVideoObject
+        remoteVideoEnabled: actionData.videoEnabled
       });
     },
 
     /**
      * Called when the window is unloaded, either by code, or by the user
      * explicitly closing it.  Expected to do any necessary housekeeping, such
      * as shutting down the call cleanly and adding any relevant telemetry data.
      */
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -265,16 +265,22 @@ loop.OTSdkDriver = (function() {
      * Disconnects the sdk session.
      */
     disconnectSession: function() {
       this.endScreenShare();
 
       this.dispatcher.dispatch(new sharedActions.DataChannelsAvailable({
         available: false
       }));
+      this.dispatcher.dispatch(new sharedActions.MediaStreamDestroyed({
+        isLocal: true
+      }));
+      this.dispatcher.dispatch(new sharedActions.MediaStreamDestroyed({
+        isLocal: false
+      }));
 
       if (this.session) {
         this.session.off("sessionDisconnected streamCreated streamDestroyed connectionCreated connectionDestroyed streamPropertyChanged");
         this.session.disconnect();
         delete this.session;
 
         this._notifyMetricsEvent("Session.connectionDestroyed", "local");
       }
@@ -584,23 +590,21 @@ loop.OTSdkDriver = (function() {
         this._mockSubscribeEl.querySelector("video");
       if (!sdkSubscriberVideo) {
         console.error("sdkSubscriberVideo unexpectedly falsy!");
       }
 
       sdkSubscriberObject.on("videoEnabled", this._onVideoEnabled.bind(this));
       sdkSubscriberObject.on("videoDisabled", this._onVideoDisabled.bind(this));
 
-      // XXX for some reason, the SDK deliberately suppresses sending the
-      // videoEnabled event after subscribe, in spite of docs claiming
-      // otherwise, so we do it ourselves.
-      if (sdkSubscriberObject.stream.hasVideo) {
-        this.dispatcher.dispatch(new sharedActions.RemoteVideoEnabled({
-          srcVideoObject: sdkSubscriberVideo}));
-      }
+      this.dispatcher.dispatch(new sharedActions.MediaStreamCreated({
+        hasVideo: sdkSubscriberObject.stream[STREAM_PROPERTIES.HAS_VIDEO],
+        isLocal: false,
+        srcVideoObject: sdkSubscriberVideo
+      }));
 
       this._subscribedRemoteStream = true;
       if (this._checkAllStreamsConnected()) {
         this._setTwoWayMediaStartTime(performance.now());
         this.dispatcher.dispatch(new sharedActions.MediaConnected());
       }
 
       this._setupDataChannelIfNeeded(sdkSubscriberObject.stream.connection);
@@ -749,23 +753,27 @@ loop.OTSdkDriver = (function() {
      * 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]) {
-
-        var sdkLocalVideo = this._mockPublisherEl.querySelector("video");
+      var sdkLocalVideo = this._mockPublisherEl.querySelector("video");
+      var hasVideo = event.stream[STREAM_PROPERTIES.HAS_VIDEO];
 
-        this.dispatcher.dispatch(new sharedActions.LocalVideoEnabled(
-              {srcVideoObject: sdkLocalVideo}));
+      this.dispatcher.dispatch(new sharedActions.MediaStreamCreated({
+        hasVideo: hasVideo,
+        isLocal: true,
+        srcVideoObject: sdkLocalVideo
+      }));
 
+      // Only dispatch the video dimensions if we actually have video.
+      if (hasVideo) {
         this.dispatcher.dispatch(new sharedActions.VideoDimensionsChanged({
           isLocal: true,
           videoType: event.stream.videoType,
           dimensions: event.stream[STREAM_PROPERTIES.VIDEO_DIMENSIONS]
         }));
       }
     },
 
@@ -828,38 +836,43 @@ loop.OTSdkDriver = (function() {
      */
     _onRemoteStreamDestroyed: function(event) {
       this._notifyMetricsEvent("Session.streamDestroyed");
 
       if (event.stream.videoType !== "screen") {
         this.dispatcher.dispatch(new sharedActions.DataChannelsAvailable({
           available: false
         }));
+        this.dispatcher.dispatch(new sharedActions.MediaStreamDestroyed({
+          isLocal: false
+        }));
         delete this._subscriberChannel;
         delete this._mockSubscribeEl;
         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
       }));
-
       delete this._mockScreenShareEl;
     },
 
     /**
      * Handles the event when the remote stream is destroyed.
      */
     _onLocalStreamDestroyed: function() {
       this._notifyMetricsEvent("Publisher.streamDestroyed");
       this.dispatcher.dispatch(new sharedActions.DataChannelsAvailable({
         available: false
       }));
+      this.dispatcher.dispatch(new sharedActions.MediaStreamDestroyed({
+        isLocal: true
+      }));
       delete this._publisherChannel;
       delete this._mockPublisherEl;
     },
 
     /**
      * 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.
@@ -944,33 +957,34 @@ loop.OTSdkDriver = (function() {
      * @private
      */
     _onVideoEnabled: function(event) {
       var sdkSubscriberVideo = this._mockSubscribeEl.querySelector("video");
       if (!sdkSubscriberVideo) {
         console.error("sdkSubscriberVideo unexpectedly falsy!");
       }
 
-      this.dispatcher.dispatch(
-        new sharedActions.RemoteVideoEnabled(
-          {srcVideoObject: sdkSubscriberVideo}));
+      this.dispatcher.dispatch(new sharedActions.RemoteVideoStatus({
+        videoEnabled: true
+      }));
     },
 
     /**
      * Handle the SDK disabling of remote video by dispatching the
      * appropriate event.
      *
      * @param event {OT.VideoEnabledChangedEvent) from the SDK
      *
      * @see https://tokbox.com/opentok/libraries/client/js/reference/VideoEnabledChangedEvent.html
      * @private
      */
     _onVideoDisabled: function(event) {
-      this.dispatcher.dispatch(
-        new sharedActions.RemoteVideoDisabled());
+      this.dispatcher.dispatch(new sharedActions.RemoteVideoStatus({
+        videoEnabled: false
+      }));
     },
 
     /**
      * Publishes the local stream if the session is connected
      * and the publisher is ready.
      */
     _maybePublishLocalStream: function() {
       if (this._sessionConnected && this._publisherReady) {
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -998,58 +998,134 @@ describe("loop.store.ActiveRoomStore", f
         type: "video",
         enabled: false
       }));
 
       expect(store.getStoreState().videoMuted).eql(true);
     });
   });
 
-  describe("#localVideoEnabled", function() {
-    it("should add a localSrcVideoObject to the store", function() {
-      var fakeVideoElement = {name: "fakeVideoElement"};
-      expect(store.getStoreState()).to.not.have.property("localSrcVideoObject");
-
-      store.localVideoEnabled({srcVideoObject: fakeVideoElement});
-
-      expect(store.getStoreState()).to.have.property("localSrcVideoObject",
-        fakeVideoElement);
-    });
-  });
-
-  describe("#remoteVideoEnabled", function() {
+  describe("#mediaStreamCreated", function() {
     var fakeVideoElement;
 
     beforeEach(function() {
       fakeVideoElement = {name: "fakeVideoElement"};
     });
 
-    it("should add a remoteSrcVideoObject to the store", function() {
+    it("should add a local video object to the store", function() {
+      expect(store.getStoreState()).to.not.have.property("localSrcVideoObject");
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: false,
+        isLocal: true,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState().localSrcVideoObject).eql(fakeVideoElement);
+      expect(store.getStoreState()).to.not.have.property("remoteSrcVideoObject");
+    });
+
+    it("should set the local video enabled", function() {
+      store.setStoreState({
+        localVideoEnabled: false,
+        remoteVideoEnabled: false
+      });
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: true,
+        isLocal: true,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState().localVideoEnabled).eql(true);
+      expect(store.getStoreState().remoteVideoEnabled).eql(false);
+    });
+
+    it("should add a remote video object to the store", function() {
       expect(store.getStoreState()).to.not.have.property("remoteSrcVideoObject");
 
-      store.remoteVideoEnabled({srcVideoObject: fakeVideoElement});
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: false,
+        isLocal: false,
+        srcVideoObject: fakeVideoElement
+      }));
 
-      expect(store.getStoreState()).to.have.property("remoteSrcVideoObject",
-        fakeVideoElement);
+      expect(store.getStoreState()).not.have.property("localSrcVideoObject");
+      expect(store.getStoreState().remoteSrcVideoObject).eql(fakeVideoElement);
     });
 
-    it("should set remoteVideoEnabled", function() {
-      store.remoteVideoEnabled({srcVideoObject: fakeVideoElement});
+    it("should set the remote video enabled", function() {
+      store.setStoreState({
+        localVideoEnabled: false,
+        remoteVideoEnabled: false
+      });
 
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: true,
+        isLocal: false,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState().localVideoEnabled).eql(false);
       expect(store.getStoreState().remoteVideoEnabled).eql(true);
     });
   });
 
-  describe("#remoteVideoDisabled", function() {
+  describe("#mediaStreamDestroyed", function() {
+    var fakeVideoElement;
+
+    beforeEach(function() {
+      fakeVideoElement = {name: "fakeVideoElement"};
+
+      store.setStoreState({
+        localSrcVideoObject: fakeVideoElement,
+        remoteSrcVideoObject: fakeVideoElement
+      });
+    });
+
+    it("should clear the local video object", function() {
+      store.mediaStreamDestroyed(new sharedActions.MediaStreamDestroyed({
+        isLocal: true
+      }));
+
+      expect(store.getStoreState().localSrcVideoObject).eql(null);
+      expect(store.getStoreState().remoteSrcVideoObject).eql(fakeVideoElement);
+    });
+
+    it("should clear the remote video object", function() {
+      store.mediaStreamDestroyed(new sharedActions.MediaStreamDestroyed({
+        isLocal: false
+      }));
+
+      expect(store.getStoreState().localSrcVideoObject).eql(fakeVideoElement);
+      expect(store.getStoreState().remoteSrcVideoObject).eql(null);
+    });
+  });
+
+  describe("#remoteVideoStatus", function() {
+    it("should set remoteVideoEnabled to true", function() {
+      store.setStoreState({
+        remoteVideoEnabled: false
+      });
+
+      store.remoteVideoStatus(new sharedActions.RemoteVideoStatus({
+        videoEnabled: true
+      }));
+
+      expect(store.getStoreState().remoteVideoEnabled).eql(true);
+    });
+
     it("should set remoteVideoEnabled to false", function() {
       store.setStoreState({
         remoteVideoEnabled: true
       });
 
-      store.remoteVideoDisabled();
+      store.remoteVideoStatus(new sharedActions.RemoteVideoStatus({
+        videoEnabled: false
+      }));
 
       expect(store.getStoreState().remoteVideoEnabled).eql(false);
     });
   });
 
   describe("#mediaConnected", function() {
     it("should set mediaConnected to true", function() {
       store.mediaConnected();
@@ -1274,16 +1350,26 @@ describe("loop.store.ActiveRoomStore", f
 
   describe("#remotePeerDisconnected", function() {
     it("should set the state to `SESSION_CONNECTED`", function() {
       store.remotePeerDisconnected();
 
       expect(store.getStoreState().roomState).eql(ROOM_STATES.SESSION_CONNECTED);
     });
 
+    it("should clear the mediaConnected state", function() {
+      store.setStoreState({
+        mediaConnected: true
+      });
+
+      store.remotePeerDisconnected();
+
+      expect(store.getStoreState().mediaConnected).eql(false);
+    });
+
     it("should clear the remoteSrcVideoObject", function() {
       store.setStoreState({
         remoteSrcVideoObject: { name: "fakeVideoElement" }
       });
 
       store.remotePeerDisconnected();
 
       expect(store.getStoreState().remoteSrcVideoObject).eql(null);
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -952,62 +952,127 @@ describe("loop.store.ConversationStore",
       store._websocket = fakeWebsocket;
 
       store.mediaConnected(new sharedActions.MediaConnected());
 
       expect(store.getStoreState("mediaConnected")).eql(true);
     });
   });
 
-  describe("#localVideoEnabled", function() {
-    it("should set store.localSrcVideoObject from the action data", function () {
-      store.localVideoEnabled(
-        new sharedActions.LocalVideoEnabled({srcVideoObject: fakeVideoElement}));
+  describe("#mediaStreamCreated", function() {
+    it("should add a local video object to the store", function() {
+      expect(store.getStoreState()).to.not.have.property("localSrcVideoObject");
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: false,
+        isLocal: true,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState().localSrcVideoObject).eql(fakeVideoElement);
+      expect(store.getStoreState()).to.not.have.property("remoteSrcVideoObject");
+    });
+
+    it("should set the local video enabled", function() {
+      store.setStoreState({
+        localVideoEnabled: false,
+        remoteVideoEnabled: false
+      });
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: true,
+        isLocal: true,
+        srcVideoObject: fakeVideoElement
+      }));
 
-      expect(store.getStoreState("localSrcVideoObject")).eql(fakeVideoElement);
+      expect(store.getStoreState().localVideoEnabled).eql(true);
+      expect(store.getStoreState().remoteVideoEnabled).eql(false);
+    });
+
+    it("should add a remote video object to the store", function() {
+      expect(store.getStoreState()).to.not.have.property("remoteSrcVideoObject");
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: false,
+        isLocal: false,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState()).not.have.property("localSrcVideoObject");
+      expect(store.getStoreState().remoteSrcVideoObject).eql(fakeVideoElement);
+    });
+
+    it("should set the remote video enabled", function() {
+      store.setStoreState({
+        localVideoEnabled: false,
+        remoteVideoEnabled: false
+      });
+
+      store.mediaStreamCreated(new sharedActions.MediaStreamCreated({
+        hasVideo: true,
+        isLocal: false,
+        srcVideoObject: fakeVideoElement
+      }));
+
+      expect(store.getStoreState().localVideoEnabled).eql(false);
+      expect(store.getStoreState().remoteVideoEnabled).eql(true);
     });
   });
 
-  describe("#remoteVideoEnabled", function() {
-    it("should set store.remoteSrcVideoObject from the actionData", function () {
-      store.setStoreState({remoteSrcVideoObject: undefined});
-
-      store.remoteVideoEnabled(
-        new sharedActions.RemoteVideoEnabled({srcVideoObject: fakeVideoElement}));
-
-      expect(store.getStoreState("remoteSrcVideoObject")).eql(fakeVideoElement);
+  describe("#mediaStreamDestroyed", function() {
+    beforeEach(function() {
+      store.setStoreState({
+        localSrcVideoObject: fakeVideoElement,
+        remoteSrcVideoObject: fakeVideoElement
+      });
     });
 
-    it("should set store.remoteVideoEnabled to true", function () {
-      store.setStoreState({remoteVideoEnabled: false});
+    it("should clear the local video object", function() {
+      store.mediaStreamDestroyed(new sharedActions.MediaStreamDestroyed({
+        isLocal: true
+      }));
 
-      store.remoteVideoEnabled(
-        new sharedActions.RemoteVideoEnabled({srcVideoObject: fakeVideoElement}));
+      expect(store.getStoreState().localSrcVideoObject).eql(null);
+      expect(store.getStoreState().remoteSrcVideoObject).eql(fakeVideoElement);
+    });
 
-      expect(store.getStoreState("remoteVideoEnabled")).to.be.true;
+    it("should clear the remote video object", function() {
+      store.mediaStreamDestroyed(new sharedActions.MediaStreamDestroyed({
+        isLocal: false
+      }));
+
+      expect(store.getStoreState().localSrcVideoObject).eql(fakeVideoElement);
+      expect(store.getStoreState().remoteSrcVideoObject).eql(null);
     });
   });
 
-  describe("#remoteVideoDisabled", function() {
-    it("should set store.remoteVideoEnabled to false", function () {
-      store.setStoreState({remoteVideoEnabled: true});
+  describe("#remoteVideoStatus", function() {
+    it("should set remoteVideoEnabled to true", function() {
+      store.setStoreState({
+        remoteVideoEnabled: false
+      });
 
-      store.remoteVideoDisabled(new sharedActions.RemoteVideoDisabled({}));
+      store.remoteVideoStatus(new sharedActions.RemoteVideoStatus({
+        videoEnabled: true
+      }));
 
-      expect(store.getStoreState("remoteVideoEnabled")).to.be.false;
+      expect(store.getStoreState().remoteVideoEnabled).eql(true);
     });
 
-    it("should set store.remoteSrcVideoObject to undefined", function () {
-      store.setStoreState({remoteSrcVideoObject: fakeVideoElement});
+    it("should set remoteVideoEnabled to false", function() {
+      store.setStoreState({
+        remoteVideoEnabled: true
+      });
 
-      store.remoteVideoDisabled(new sharedActions.RemoteVideoDisabled({}));
+      store.remoteVideoStatus(new sharedActions.RemoteVideoStatus({
+        videoEnabled: false
+      }));
 
-      expect(store.getStoreState("remoteSrcVideoObject")).to.be.undefined;
+      expect(store.getStoreState().remoteVideoEnabled).eql(false);
     });
-
   });
 
   describe("#setMute", function() {
     beforeEach(function() {
       dispatcher.dispatch(
         // Setup store to prevent console warnings.
         new sharedActions.SetupWindowData({
           windowId: "123456",
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -411,23 +411,43 @@ describe("loop.OTSdkDriver", function ()
       driver.disconnectSession();
 
       sinon.assert.calledOnce(session.disconnect);
     });
 
     it("should dispatch a DataChannelsAvailable action with available = false", function() {
       driver.disconnectSession();
 
-      sinon.assert.calledOnce(dispatcher.dispatch);
+      sinon.assert.called(dispatcher.dispatch);
       sinon.assert.calledWithExactly(dispatcher.dispatch,
         new sharedActions.DataChannelsAvailable({
           available: false
         }));
     });
 
+    it("should dispatch a MediaStreamDestroyed action with isLocal = false", function() {
+      driver.disconnectSession();
+
+      sinon.assert.called(dispatcher.dispatch);
+      sinon.assert.calledWithExactly(dispatcher.dispatch,
+        new sharedActions.MediaStreamDestroyed({
+          isLocal: false
+        }));
+    });
+
+    it("should dispatch a MediaStreamDestroyed action with isLocal = true", function() {
+      driver.disconnectSession();
+
+      sinon.assert.called(dispatcher.dispatch);
+      sinon.assert.calledWithExactly(dispatcher.dispatch,
+        new sharedActions.MediaStreamDestroyed({
+          isLocal: true
+        }));
+    });
+
     it("should destroy the publisher", function() {
       driver.publisher = publisher;
 
       driver.disconnectSession();
 
       sinon.assert.calledOnce(publisher.destroy);
     });
 
@@ -808,22 +828,37 @@ describe("loop.OTSdkDriver", function ()
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.VideoDimensionsChanged({
             isLocal: true,
             videoType: "camera",
             dimensions: {width: 1, height: 2}
           }));
       });
 
-      it("should dispatch a LocalVideoEnabled action", function() {
+      it("should dispatch a MediaStreamCreated action", function() {
         publisher.trigger("streamCreated", { stream: stream });
 
         sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.LocalVideoEnabled({
+          new sharedActions.MediaStreamCreated({
+            hasVideo: true,
+            isLocal: true,
+            srcVideoObject: fakeMockVideo
+          }));
+      });
+
+      it("should dispatch a MediaStreamCreated action with hasVideo false for audio-only streams", function() {
+        stream.hasVideo = false;
+        publisher.trigger("streamCreated", { stream: stream });
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MediaStreamCreated({
+            hasVideo: false,
+            isLocal: true,
             srcVideoObject: fakeMockVideo
           }));
       });
 
       it("should dispatch a ConnectionStatus action", function() {
         driver._metrics.recvStreams = 1;
         driver._metrics.connections = 2;
 
@@ -875,44 +910,47 @@ describe("loop.OTSdkDriver", function ()
         session.trigger("streamCreated", { stream: fakeStream });
 
         sinon.assert.calledOnce(session.subscribe);
         sinon.assert.calledWithExactly(session.subscribe,
           fakeStream, sinon.match.instanceOf(HTMLDivElement), publisherConfig,
           sinon.match.func);
       });
 
-      it("should dispatch RemoteVideoEnabled if the stream has video" +
-        " after subscribe is complete", function() {
+      it("should dispatch MediaStreamCreated after subscribe is complete", function() {
         session.subscribe.yieldsOn(driver, null, fakeSubscriberObject,
           videoElement).returns(this.fakeSubscriberObject);
         driver.session = session;
         fakeStream.connection = fakeConnection;
         fakeStream.hasVideo = true;
 
         session.trigger("streamCreated", { stream: fakeStream });
 
         sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.RemoteVideoEnabled({
+          new sharedActions.MediaStreamCreated({
+            hasVideo: true,
+            isLocal: false,
             srcVideoObject: videoElement
           }));
       });
 
-      it("should not dispatch RemoteVideoEnabled if the stream is audio-only", function() {
+      it("should dispatch MediaStreamCreated after subscribe with audio-only indication if hasVideo=false", function() {
         session.subscribe.yieldsOn(driver, null, fakeSubscriberObject,
           videoElement);
         fakeStream.connection = fakeConnection;
         fakeStream.hasVideo = false;
 
         session.trigger("streamCreated", { stream: fakeStream });
 
         sinon.assert.called(dispatcher.dispatch);
-        sinon.assert.neverCalledWith(dispatcher.dispatch,
-          new sharedActions.RemoteVideoEnabled({
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MediaStreamCreated({
+            hasVideo: false,
+            isLocal: false,
             srcVideoObject: videoElement
           }));
       });
 
       it("should trigger a readyForDataChannel signal after subscribe is complete", function() {
         session.subscribe.yieldsOn(driver, null, fakeSubscriberObject,
           document.createElement("video"));
         driver._useDataChannels = true;
@@ -1026,36 +1064,46 @@ describe("loop.OTSdkDriver", function ()
     describe("streamDestroyed: publisher/local", function() {
       it("should dispatch a ConnectionStatus action", function() {
         driver._metrics.sendStreams = 1;
         driver._metrics.recvStreams = 1;
         driver._metrics.connections = 2;
 
         publisher.trigger("streamDestroyed");
 
-        sinon.assert.calledTwice(dispatcher.dispatch);
+        sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.ConnectionStatus({
             event: "Publisher.streamDestroyed",
             state: "receiving",
             connections: 2,
             recvStreams: 1,
             sendStreams: 0
           }));
       });
 
       it("should dispatch a DataChannelsAvailable action", function() {
         publisher.trigger("streamDestroyed");
 
-        sinon.assert.calledTwice(dispatcher.dispatch);
+        sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.DataChannelsAvailable({
             available: false
           }));
       });
+
+      it("should dispatch a MediaStreamDestroyed action", function() {
+        publisher.trigger("streamDestroyed");
+
+        sinon.assert.called(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MediaStreamDestroyed({
+            isLocal: true
+          }));
+      });
     });
 
     describe("streamDestroyed: session/remote", function() {
       var stream;
 
       beforeEach(function() {
         stream = {
           videoType: "screen"
@@ -1099,29 +1147,50 @@ describe("loop.OTSdkDriver", function ()
           sinon.match.hasOwn("name", "receivingScreenShare"));
       });
 
       it("should dispatch a DataChannelsAvailable action for videoType = camera", function() {
         stream.videoType = "camera";
 
         session.trigger("streamDestroyed", { stream: stream });
 
-        sinon.assert.calledTwice(dispatcher.dispatch);
+        sinon.assert.calledThrice(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.DataChannelsAvailable({
             available: false
           }));
       });
 
       it("should not dispatch a DataChannelsAvailable action for videoType = screen", function() {
         session.trigger("streamDestroyed", { stream: stream });
 
         sinon.assert.neverCalledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "dataChannelsAvailable"));
       });
+
+      it("should dispatch a MediaStreamDestroyed action for videoType = camera", function() {
+        stream.videoType = "camera";
+
+        session.trigger("streamDestroyed", { stream: stream });
+
+        sinon.assert.calledThrice(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.MediaStreamDestroyed({
+            isLocal: false
+          }));
+      });
+
+      it("should not dispatch a MediaStreamDestroyed action for videoType = screen", function() {
+        session.trigger("streamDestroyed", { stream: stream });
+
+        sinon.assert.neverCalledWithMatch(dispatcher.dispatch,
+          new sharedActions.MediaStreamDestroyed({
+            isLocal: false
+          }));
+      });
     });
 
     describe("streamPropertyChanged", function() {
       var stream = {
         connection: { id: "fake" },
         videoType: "screen",
         videoDimensions: {
           width: 320,
@@ -1281,42 +1350,46 @@ describe("loop.OTSdkDriver", function ()
       it("should prevent the default event behavior", function() {
         publisher.trigger("accessDialogOpened", fakeEvent);
 
         sinon.assert.calledOnce(fakeEvent.preventDefault);
       });
     });
 
     describe("videoEnabled", function() {
-      it("should dispatch RemoteVideoEnabled", function() {
+      it("should dispatch a RemoteVideoStatus action", function() {
         session.subscribe.yieldsOn(driver, null, fakeSubscriberObject,
           videoElement).returns(this.fakeSubscriberObject);
         session.trigger("streamCreated", {stream: fakeSubscriberObject.stream});
         driver._mockSubscribeEl.appendChild(videoElement);
 
         fakeSubscriberObject.trigger("videoEnabled");
 
         sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWith(dispatcher.dispatch,
-          new sharedActions.RemoteVideoEnabled({srcVideoObject: videoElement}));
+          new sharedActions.RemoteVideoStatus({
+            videoEnabled: true
+          }));
       });
     });
 
     describe("videoDisabled", function() {
-      it("should dispatch RemoteVideoDisabled", function() {
+      it("should dispatch a RemoteVideoStatus action", function() {
         session.subscribe.yieldsOn(driver, null, fakeSubscriberObject,
           videoElement).returns(this.fakeSubscriberObject);
         session.trigger("streamCreated", {stream: fakeSubscriberObject.stream});
 
 
         fakeSubscriberObject.trigger("videoDisabled");
 
         sinon.assert.called(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
-          new sharedActions.RemoteVideoDisabled({}));
+          new sharedActions.RemoteVideoStatus({
+            videoEnabled: false
+          }));
       });
     });
 
     describe("signal:readyForDataChannel", function() {
       beforeEach(function() {
         driver.subscriber = subscriber;
         driver._useDataChannels = true;
       });