Bug 1093780 Part 2 - Add support for using 'contain' mode for all video streams Loop publishes and resize/ position the elements based on their aspect ratio. r=Standard8
authorMike de Boer <mdeboer@mozilla.com>
Fri, 30 Jan 2015 16:01:42 +0000
changeset 226885 9e204cdb756ce15f6b1857c0f1ba9244452eb1e8
parent 226884 dcf6ab90ff977c1abb20769a696aadb5cfe0f59d
child 226886 e8201b76639d7ffa3c7c271eb683e3acc35c8c31
push id28208
push userphilringnalda@gmail.com
push dateSat, 31 Jan 2015 17:06:44 +0000
treeherdermozilla-central@426bc5ee47d9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1093780
milestone38.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1093780 Part 2 - Add support for using 'contain' mode for all video streams Loop publishes and resize/ position the elements based on their aspect ratio. r=Standard8
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/mixins.js
browser/components/loop/content/shared/js/otSdkDriver.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -1,18 +1,13 @@
 /* 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/. */
 
 /* Shared conversation window styles */
-.standalone .video-layout-wrapper,
-.conversation .media video {
-  background-color: #444;
-}
-
 .conversation {
   position: relative;
 }
 
 .conversation-toolbar {
   z-index: 999; /* required to have it superimposed to the video element */
   border: 1px solid #5a5a5a;
   border-left: 0;
@@ -668,17 +663,16 @@ html, .fx-embedded, #main,
 
   .standalone .remote_wrapper {
     position: relative;
     width: 100%;
     height: 100%;
   }
 
   .standalone {
-    max-width: 1000px;
     margin: 0 auto;
   }
 }
 
 @media screen and (max-width:640px) {
   .standalone .video-layout-wrapper,
   .standalone .conversation {
     height: 100%;
@@ -900,21 +894,16 @@ html, .fx-embedded, #main,
   background: #000;
 }
 
 .standalone .room-conversation .video_wrapper.remote_wrapper {
   background-color: #4e4e4e;
   width: 75%;
 }
 
-.standalone .room-conversation .local-stream {
-  width: 33%;
-  height: 26.5%;
-}
-
 .standalone .room-conversation .conversation-toolbar {
   background: #000;
   border: none;
 }
 
 .standalone .room-conversation .conversation-toolbar .btn-hangup-entry {
   display: block;
 }
@@ -940,21 +929,16 @@ html, .fx-embedded, #main,
   }
   .standalone .room-conversation-wrapper .video-layout-wrapper {
     /* 50px: header's height; 25px: footer's height */
     height: calc(100% - 50px - 25px);
   }
   .standalone .room-conversation .video_wrapper.remote_wrapper {
     width: 100%;
   }
-  .standalone .room-conversation .local-stream {
-    /* Assumes 4:3 aspect ratio */
-    width: 180px;
-    height: 135px;
-  }
   .standalone .conversation-toolbar {
     height: 38px;
     padding: 8px;
   }
   .standalone .media.nested {
     /* This forces the remote video stream to fit within wrapper's height */
     min-height: 0px;
   }
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -172,16 +172,25 @@ loop.shared.actions = (function() {
 
     /**
      * Used for notifying that the media is now up for the call.
      */
     MediaConnected: Action.define("mediaConnected", {
     }),
 
     /**
+     * Used for notifying that the dimensions of a stream just changed. Also
+     * dispatched when a stream connects for the first time.
+     */
+    VideoDimensionsChanged: Action.define("videoDimensionsChanged", {
+      videoType: String,
+      dimensions: Object
+    }),
+
+    /**
      * 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,
       // Whether or not to enable the stream.
       enabled: Boolean
     }),
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -63,17 +63,19 @@ loop.store.ActiveRoomStore = (function()
         roomState: ROOM_STATES.INIT,
         audioMuted: false,
         videoMuted: false,
         failureReason: undefined,
         // Tracks if the room has been used during this
         // session. 'Used' means at least one call has been placed
         // with it. Entering and leaving the room without seeing
         // anyone is not considered as 'used'
-        used: false
+        used: false,
+        localVideoDimensions: {},
+        remoteVideoDimensions: {}
       };
     },
 
     /**
      * Handles a room failure.
      *
      * @param {sharedActions.RoomFailure} actionData
      */
@@ -114,17 +116,18 @@ loop.store.ActiveRoomStore = (function()
         "joinedRoom",
         "connectedToSdkServers",
         "connectionFailure",
         "setMute",
         "remotePeerDisconnected",
         "remotePeerConnected",
         "windowUnload",
         "leaveRoom",
-        "feedbackComplete"
+        "feedbackComplete",
+        "videoDimensionsChanged"
       ]);
     },
 
     /**
      * Execute setupWindowData event action from the dispatcher. This gets
      * the room data from the mozLoop api, and dispatches an UpdateRoomInfo event.
      * It also dispatches JoinRoom as this action is only applicable to the desktop
      * client, and needs to auto-join.
@@ -472,13 +475,30 @@ loop.store.ActiveRoomStore = (function()
 
     /**
      * When feedback is complete, we reset the room to the initial state.
      */
     feedbackComplete: function() {
       // Note, that we want some values, such as the windowId, so we don't
       // do a full reset here.
       this.setStoreState(this.getInitialStoreState());
+    },
+
+    /**
+     * Handles a change in dimensions of a video stream and updates the store data
+     * with the new dimensions of a local or remote stream.
+     *
+     * @param {sharedActions.VideoDimensionsChanged} actionData
+     */
+    videoDimensionsChanged: function(actionData) {
+      // NOTE: in the future, when multiple remote video streams are supported,
+      //       we'll need to make this support multiple remotes as well. Good
+      //       starting point for video tiling.
+      var storeProp = (actionData.isLocal ? "local" : "remote") + "VideoDimensions";
+      var nextState = {};
+      nextState[storeProp] = this.getStoreState()[storeProp];
+      nextState[storeProp][actionData.videoType] = actionData.dimensions;
+      this.setStoreState(nextState);
     }
   });
 
   return ActiveRoomStore;
 })();
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -152,39 +152,227 @@ loop.shared.mixins = (function() {
     }
   };
 
   /**
    * Media setup mixin. Provides a common location for settings for the media
    * elements and handling updates of the media containers.
    */
   var MediaSetupMixin = {
+    _videoDimensionsCache: {
+      local: {},
+      remote: {}
+    },
+
     componentDidMount: function() {
-      rootObject.addEventListener('orientationchange', this.updateVideoContainer);
-      rootObject.addEventListener('resize', this.updateVideoContainer);
+      rootObject.addEventListener("orientationchange", this.updateVideoContainer);
+      rootObject.addEventListener("resize", this.updateVideoContainer);
     },
 
     componentWillUnmount: function() {
-      rootObject.removeEventListener('orientationchange', this.updateVideoContainer);
-      rootObject.removeEventListener('resize', this.updateVideoContainer);
+      rootObject.removeEventListener("orientationchange", this.updateVideoContainer);
+      rootObject.removeEventListener("resize", this.updateVideoContainer);
+    },
+
+    /**
+     * Whenever the dimensions change of a video stream, this function is called
+     * by `updateVideoDimensions` to store the new values and notifies the callee
+     * if the dimensions changed compared to the currently stored values.
+     *
+     * @param  {String} which         Type of video stream. May be 'local' or 'remote'
+     * @param  {Object} newDimensions Object containing 'width' and 'height' properties
+     * @return {Boolean}              `true` when the dimensions have changed,
+     *                                `false` if not
+     */
+    _updateDimensionsCache: function(which, newDimensions) {
+      var cache = this._videoDimensionsCache[which];
+      var cacheKeys = Object.keys(cache);
+      var changed = false;
+      Object.keys(newDimensions).forEach(function(videoType) {
+        if (cacheKeys.indexOf(videoType) === -1) {
+          cache[videoType] = newDimensions[videoType];
+          cache[videoType].aspectRatio = this.getAspectRatio(cache[videoType]);
+          changed = true;
+          return;
+        }
+        if (cache[videoType].width !== newDimensions[videoType].width) {
+          cache[videoType].width = newDimensions[videoType].width;
+          changed = true;
+        }
+        if (cache[videoType].height !== newDimensions[videoType].height) {
+          cache[videoType].height = newDimensions[videoType].height;
+          changed = true;
+        }
+        if (changed) {
+          cache[videoType].aspectRatio = this.getAspectRatio(cache[videoType]);
+        }
+      }, this);
+      return changed;
+    },
+
+    /**
+     * Whenever the dimensions change of a video stream, this function is called
+     * to process these changes and possibly trigger an update to the video
+     * container elements.
+     *
+     * @param  {Object} localVideoDimensions  Object containing 'width' and 'height'
+     *                                        properties grouped by stream name
+     * @param  {Object} remoteVideoDimensions Object containing 'width' and 'height'
+     *                                        properties grouped by stream name
+     */
+    updateVideoDimensions: function(localVideoDimensions, remoteVideoDimensions) {
+      var localChanged = this._updateDimensionsCache("local", localVideoDimensions);
+      var remoteChanged = this._updateDimensionsCache("remote", remoteVideoDimensions);
+      if (localChanged || remoteChanged) {
+        this.updateVideoContainer();
+      }
+    },
+
+    /**
+     * Get the aspect ratio of a width/ height pair, which should be the dimensions
+     * of a stream. The returned object is an aspect ratio indexed by 1; the leading
+     * size has a value smaller than 1 and the slave size has a value of 1.
+     * this is exactly the same as notations like 4:3 and 16:9, which are merely
+     * human-readable forms of their fractional counterparts. 4:3 === 1:0.75 and
+     * 16:9 === 1:0.5625.
+     * So we're using the aspect ratios in their original form, because that's
+     * easier to do calculus with.
+     *
+     * Example:
+     * A stream with dimensions `{ width: 640, height: 480 }` yields an indexed
+     * aspect ratio of `{ width: 1, height: 0.75 }`. This means that the 'height'
+     * will determine the value of 'width' when the stream is stretched or shrunk
+     * to fit inside its container element at the maximum size.
+     *
+     * @param  {Object} dimensions Object containing 'width' and 'height' properties
+     * @return {Object}            Contains the indexed aspect ratio for 'width'
+     *                             and 'height' assigned to the corresponding
+     *                             properties.
+     */
+    getAspectRatio: function(dimensions) {
+      if (dimensions.width === dimensions.height) {
+        return {width: 1, height: 1};
+      }
+      var denominator = Math.max(dimensions.width, dimensions.height);
+      return {
+        width: dimensions.width / denominator,
+        height: dimensions.height / denominator
+      };
+    },
+
+    /**
+     * Retrieve the dimensions of the remote video stream.
+     * Example output:
+     *   {
+     *     width: 680,
+     *     height: 480,
+     *     streamWidth: 640,
+     *     streamHeight: 480,
+     *     offsetX: 20,
+     *     offsetY: 0
+     *   }
+     *
+     * Note: Once we support multiple remote video streams, this function will
+     *       need to be updated.
+     * @return {Object} contains the remote stream dimension properties of its
+     *                  container node, the stream itself and offset of the stream
+     *                  relative to its container node in pixels.
+     */
+    getRemoteVideoDimensions: function() {
+      var remoteVideoDimensions;
+
+      Object.keys(this._videoDimensionsCache.remote).forEach(function(videoType) {
+        var node = this._getElement("." + (videoType === "camera" ? "remote" : videoType));
+        var width = node.offsetWidth;
+        // If the width > 0 then we record its real size by taking its aspect
+        // ratio in account. Due to the 'contain' fit-mode, the stream will be
+        // centered inside the video element.
+        // We'll need to deal with more than one remote video stream whenever
+        // that becomes something we need to support.
+        if (width) {
+          remoteVideoDimensions = {
+            width: width,
+            height: node.offsetHeight
+          };
+          var ratio = this._videoDimensionsCache.remote[videoType].aspectRatio;
+          var leadingAxis = Math.min(ratio.width, ratio.height) === ratio.width ?
+            "width" : "height";
+          var slaveSize = remoteVideoDimensions[leadingAxis] +
+            (remoteVideoDimensions[leadingAxis] * (1 - ratio[leadingAxis]));
+          remoteVideoDimensions.streamWidth = leadingAxis === "width" ?
+            remoteVideoDimensions.width : slaveSize;
+          remoteVideoDimensions.streamHeight = leadingAxis === "height" ?
+            remoteVideoDimensions.height: slaveSize;
+        }
+      }, this);
+
+      // Supply some sensible defaults for the remoteVideoDimensions if no remote
+      // stream is connected (yet).
+      if (!remoteVideoDimensions) {
+        var node = this._getElement(".remote");
+        var width = node.offsetWidth;
+        var height = node.offsetHeight;
+        remoteVideoDimensions = {
+          width: width,
+          height: height,
+          streamWidth: width,
+          streamHeight: height
+        };
+      }
+
+      // Calculate the size of each individual letter- or pillarbox for convenience.
+      remoteVideoDimensions.offsetX = remoteVideoDimensions.width -
+        remoteVideoDimensions.streamWidth
+      if (remoteVideoDimensions.offsetX > 0) {
+        remoteVideoDimensions.offsetX /= 2;
+      }
+      remoteVideoDimensions.offsetY = remoteVideoDimensions.height -
+        remoteVideoDimensions.streamHeight;
+      if (remoteVideoDimensions.offsetY > 0) {
+        remoteVideoDimensions.offsetY /= 2;
+      }
+
+      return remoteVideoDimensions;
     },
 
     /**
      * Used to update the video container whenever the orientation or size of the
      * display area changes.
+     *
+     * Buffer the calls to this function to make sure we don't overflow the stack
+     * with update calls when many 'resize' event are fired, to prevent blocking
+     * the event loop.
      */
     updateVideoContainer: function() {
-      var localStreamParent = this._getElement('.local .OT_publisher');
-      var remoteStreamParent = this._getElement('.remote .OT_subscriber');
-      if (localStreamParent) {
-        localStreamParent.style.width = "100%";
+      if (this._bufferedUpdateVideo) {
+        rootObject.clearTimeout(this._bufferedUpdateVideo);
+        this._bufferedUpdateVideo = null;
       }
-      if (remoteStreamParent) {
-        remoteStreamParent.style.height = "100%";
-      }
+
+      this._bufferedUpdateVideo = rootObject.setTimeout(function() {
+        this._bufferedUpdateVideo = null;
+        var localStreamParent = this._getElement(".local .OT_publisher");
+        var remoteStreamParent = this._getElement(".remote .OT_subscriber");
+        if (localStreamParent) {
+          localStreamParent.style.width = "100%";
+        }
+        if (remoteStreamParent) {
+          remoteStreamParent.style.height = "100%";
+        }
+
+        // Update the position and dimensions of the containers of local video
+        // streams, if necessary. The consumer of this mixin should implement the
+        // actual updating mechanism.
+        Object.keys(this._videoDimensionsCache.local).forEach(function(videoType) {
+          var ratio = this._videoDimensionsCache.local[videoType].aspectRatio
+          if (videoType == "camera" && this.updateLocalCameraPosition) {
+            this.updateLocalCameraPosition(ratio);
+          }
+        }, this);
+      }.bind(this), 0);
     },
 
     /**
      * Returns the default configuration for publishing media on the sdk.
      *
      * @param {Object} options An options object containing:
      * - publishVideo A boolean set to true to publish video when the stream is initiated.
      */
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -4,16 +4,17 @@
 
 /* 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;
 
   /**
    * 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");
@@ -46,16 +47,17 @@ loop.OTSdkDriver = (function() {
       this.getRemoteElement = actionData.getRemoteElementFunc;
       this.publisherConfig = actionData.publisherConfig;
 
       // 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.publisher = this.sdk.initPublisher(this.getLocalElement(),
         this.publisherConfig);
+      this.publisher.on("streamCreated", this._onLocalStreamCreated.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));
     },
 
     /**
      * Handles the setMute action. Informs the published stream to mute
@@ -86,33 +88,35 @@ loop.OTSdkDriver = (function() {
       this.session = this.sdk.initSession(sessionData.sessionId);
 
       this.session.on("connectionCreated", this._onConnectionCreated.bind(this));
       this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
       this.session.on("connectionDestroyed",
         this._onConnectionDestroyed.bind(this));
       this.session.on("sessionDisconnected",
         this._onSessionDisconnected.bind(this));
+      this.session.on("streamPropertyChanged", this._onStreamPropertyChanged.bind(this));
 
       // This starts the actual session connection.
       this.session.connect(sessionData.apiKey, sessionData.sessionToken,
         this._onConnectionComplete.bind(this));
     },
 
     /**
      * Disconnects the sdk session.
      */
     disconnectSession: function() {
       if (this.session) {
-        this.session.off("streamCreated connectionDestroyed sessionDisconnected");
+        this.session.off("streamCreated streamDestroyed connectionDestroyed " +
+          "sessionDisconnected streamPropertyChanged");
         this.session.disconnect();
         delete this.session;
       }
       if (this.publisher) {
-        this.publisher.off("accessAllowed accessDenied accessDialogOpened");
+        this.publisher.off("accessAllowed accessDenied accessDialogOpened streamCreated");
         this.publisher.destroy();
         delete this.publisher;
       }
 
       // Also, tidy these variables ready for next time.
       delete this._sessionConnected;
       delete this._publisherReady;
       delete this._publishedLocalStream;
@@ -229,26 +233,50 @@ loop.OTSdkDriver = (function() {
 
     /**
      * 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) {
+      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]
+        }));
+      }
+
       this.session.subscribe(event.stream,
         this.getRemoteElement(), this.publisherConfig);
 
       this._subscribedRemoteStream = true;
       if (this._checkAllStreamsConnected()) {
         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) {
+      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]
+        }));
+      }
+    },
+
+    /**
      * 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();
@@ -278,16 +306,29 @@ loop.OTSdkDriver = (function() {
       event.preventDefault();
 
       this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
         reason: FAILURE_DETAILS.MEDIA_DENIED
       }));
     },
 
     /**
+     * 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);
 
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -37,16 +37,22 @@ loop.shared.utils = (function(mozL10n) {
   var FAILURE_DETAILS = {
     MEDIA_DENIED: "reason-media-denied",
     COULD_NOT_CONNECT: "reason-could-not-connect",
     NETWORK_DISCONNECTED: "reason-network-disconnected",
     EXPIRED_OR_INVALID: "reason-expired-or-invalid",
     UNKNOWN: "reason-unknown"
   };
 
+  var STREAM_PROPERTIES = {
+    VIDEO_DIMENSIONS: "videoDimensions",
+    HAS_AUDIO: "hasAudio",
+    HAS_VIDEO: "hasVideo"
+  };
+
   /**
    * Format a given date into an l10n-friendly string.
    *
    * @param {Integer} The timestamp in seconds to format.
    * @return {String} The formatted string.
    */
   function formatDate(timestamp) {
     var date = (new Date(timestamp * 1000));
@@ -133,14 +139,15 @@ loop.shared.utils = (function(mozL10n) {
     );
   }
 
   return {
     CALL_TYPES: CALL_TYPES,
     FAILURE_DETAILS: FAILURE_DETAILS,
     REST_ERRNOS: REST_ERRNOS,
     WEBSOCKET_REASONS: WEBSOCKET_REASONS,
+    STREAM_PROPERTIES: STREAM_PROPERTIES,
     Helper: Helper,
     composeCallUrlEmail: composeCallUrlEmail,
     formatDate: formatDate,
     getBoolPreference: getBoolPreference
   };
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -219,17 +219,19 @@ loop.standaloneRoomViews = (function(moz
 
     /**
      * Handles a "change" event on the roomStore, and updates this.state
      * to match the store.
      *
      * @private
      */
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.activeRoomStore.getStoreState());
+      var state = this.props.activeRoomStore.getStoreState();
+      this.updateVideoDimensions(state.localVideoDimensions, state.remoteVideoDimensions);
+      this.setState(state);
     },
 
     componentDidMount: function() {
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
@@ -279,16 +281,51 @@ loop.standaloneRoomViews = (function(moz
     publishStream: function(type, enabled) {
       this.props.dispatcher.dispatch(new sharedActions.SetMute({
         type: type,
         enabled: enabled
       }));
     },
 
     /**
+     * Specifically updates the local camera stream size and position, depending
+     * on the size and position of the remote video stream.
+     * This method gets called from `updateVideoContainer`, which is defined in
+     * the `MediaSetupMixin`.
+     *
+     * @param  {Object} ratio Aspect ratio of the local camera stream
+     */
+    updateLocalCameraPosition: function(ratio) {
+      var node = this._getElement(".local");
+      var parent = node.offsetParent || this._getElement(".media");
+      // The local camera view should be a sixth of the size of its offset parent
+      // and positioned to overlap with the remote stream at a quarter of its width.
+      var parentWidth = parent.offsetWidth;
+      var targetWidth = parentWidth / 6;
+
+      node.style.right = "auto";
+      if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
+        targetWidth = 180;
+        node.style.left = "auto";
+      } else {
+        // Now position the local camera view correctly with respect to the remote
+        // video stream.
+        var remoteVideoDimensions = this.getRemoteVideoDimensions();
+        var offsetX = (remoteVideoDimensions.streamWidth + remoteVideoDimensions.offsetX);
+        // The horizontal offset of the stream, and the width of the resulting
+        // pillarbox, is determined by the height exponent of the aspect ratio.
+        // Therefore we multiply the width of the local camera view by the height
+        // ratio.
+        node.style.left = (offsetX - ((targetWidth * ratio.height) / 4)) + "px";
+      }
+      node.style.width = (targetWidth * ratio.width) + "px";
+      node.style.height = (targetWidth * ratio.height) + "px";
+    },
+
+    /**
      * Checks if current room is active.
      *
      * @return {Boolean}
      */
     _roomIsActive: function() {
       return this.state.roomState === ROOM_STATES.JOINED            ||
              this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
              this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -219,17 +219,19 @@ loop.standaloneRoomViews = (function(moz
 
     /**
      * Handles a "change" event on the roomStore, and updates this.state
      * to match the store.
      *
      * @private
      */
     _onActiveRoomStateChanged: function() {
-      this.setState(this.props.activeRoomStore.getStoreState());
+      var state = this.props.activeRoomStore.getStoreState();
+      this.updateVideoDimensions(state.localVideoDimensions, state.remoteVideoDimensions);
+      this.setState(state);
     },
 
     componentDidMount: function() {
       // Adding a class to the document body element from here to ease styling it.
       document.body.classList.add("is-standalone-room");
     },
 
     componentWillUnmount: function() {
@@ -279,16 +281,51 @@ loop.standaloneRoomViews = (function(moz
     publishStream: function(type, enabled) {
       this.props.dispatcher.dispatch(new sharedActions.SetMute({
         type: type,
         enabled: enabled
       }));
     },
 
     /**
+     * Specifically updates the local camera stream size and position, depending
+     * on the size and position of the remote video stream.
+     * This method gets called from `updateVideoContainer`, which is defined in
+     * the `MediaSetupMixin`.
+     *
+     * @param  {Object} ratio Aspect ratio of the local camera stream
+     */
+    updateLocalCameraPosition: function(ratio) {
+      var node = this._getElement(".local");
+      var parent = node.offsetParent || this._getElement(".media");
+      // The local camera view should be a sixth of the size of its offset parent
+      // and positioned to overlap with the remote stream at a quarter of its width.
+      var parentWidth = parent.offsetWidth;
+      var targetWidth = parentWidth / 6;
+
+      node.style.right = "auto";
+      if (window.matchMedia && window.matchMedia("screen and (max-width:640px)").matches) {
+        targetWidth = 180;
+        node.style.left = "auto";
+      } else {
+        // Now position the local camera view correctly with respect to the remote
+        // video stream.
+        var remoteVideoDimensions = this.getRemoteVideoDimensions();
+        var offsetX = (remoteVideoDimensions.streamWidth + remoteVideoDimensions.offsetX);
+        // The horizontal offset of the stream, and the width of the resulting
+        // pillarbox, is determined by the height exponent of the aspect ratio.
+        // Therefore we multiply the width of the local camera view by the height
+        // ratio.
+        node.style.left = (offsetX - ((targetWidth * ratio.height) / 4)) + "px";
+      }
+      node.style.width = (targetWidth * ratio.width) + "px";
+      node.style.height = (targetWidth * ratio.height) + "px";
+    },
+
+    /**
      * Checks if current room is active.
      *
      * @return {Boolean}
      */
     _roomIsActive: function() {
       return this.state.roomState === ROOM_STATES.JOINED            ||
              this.state.roomState === ROOM_STATES.SESSION_CONNECTED ||
              this.state.roomState === ROOM_STATES.HAS_PARTICIPANTS;