Bug 1339269 - Enhance controls adjustment against delay layout reflow. r=jaws, a=gchang
authorRay Lin <ralin@mozilla.com>
Fri, 10 Feb 2017 17:08:44 +0800
changeset 376517 a3fe876dacfbd1f5610df0df020d295626b2d4dd
parent 376516 25abc94c7a6e3524aecda2844188760081df7328
child 376518 1d2d7278570a8c83c4d8d327f0707f9619305209
push id6996
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 20:48:21 +0000
treeherdermozilla-beta@d89512dab048 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws, gchang
bugs1339269
milestone53.0a2
Bug 1339269 - Enhance controls adjustment against delay layout reflow. r=jaws, a=gchang MozReview-Commit-ID: KABc6RkCCS5
toolkit/content/widgets/videocontrols.xml
--- a/toolkit/content/widgets/videocontrols.xml
+++ b/toolkit/content/widgets/videocontrols.xml
@@ -207,16 +207,17 @@
       scrubber       : null,
       progressBar    : null,
       bufferBar      : null,
       statusOverlay  : null,
       controlsSpacer : null,
       clickToPlay    : null,
       controlsOverlay : null,
       fullscreenButton : null,
+      layoutControls : null,
       currentTextTrackIndex: 0,
 
       textTracksCount: 0,
       randomID : 0,
       videoEvents : ["play", "pause", "ended", "volumechange", "loadeddata",
                      "loadstart", "timeupdate", "progress",
                      "playing", "waiting", "canplay", "canplaythrough",
                      "seeking", "seeked", "emptied", "loadedmetadata",
@@ -234,17 +235,17 @@
       set isAudioOnly(val) {
         this._isAudioOnly = val;
         this.setFullscreenButtonState();
 
         if (!this.isTopLevelSyntheticDocument) {
           return;
         }
         if (this._isAudioOnly) {
-          this.video.style.height = this.controlBar.minHeight + "px";
+          this.video.style.height = this.controlBarMinHeight + "px";
           this.video.style.width = "66%";
         } else {
           this.video.style.removeProperty("height");
           this.video.style.removeProperty("width");
         }
       },
 
       get isControlBarHidden() {
@@ -359,51 +360,46 @@
           this.statusIcon.setAttribute("type", "error");
           this.updateErrorText();
           this.setupStatusFader(true);
         }
 
         // An event handler for |onresize| should be added when bug 227495 is fixed.
         this.controlBar.hidden = false;
 
-        let layoutControls = [
-          ...this.controlBar.children,
-          this.durationSpan,
-          this.controlBar,
-          this.clickToPlay
-        ];
-
-        for (let control of layoutControls) {
+        for (let control of this.layoutControls) {
           if (!control) {
             break;
           }
 
           Object.defineProperties(control, {
             minWidth: {
               value: control.clientWidth,
               writable: true
             },
-            minHeight: {
-              value: control.clientHeight,
-              writable: true
-            },
             isAdjustableControl: {
               value: true
             },
             isWanted: {
               value: true,
               writable: true
             },
             resized: {
               value: false,
               writable: true
             },
             resizedHandler: {
               value: () => {
-                control.minWidth = control.clientWidth;
+                let width = control.clientWidth;
+
+                if (width === 0) {
+                  return;
+                }
+
+                control.minWidth = width;
               },
               writable: true
             },
             hideByAdjustment: {
               set: (v) => {
                 if (v) {
                   control.setAttribute("hidden", "true");
                 } else {
@@ -417,27 +413,28 @@
             _isHiddenByAdjustment: {
               value: false,
               writable: true
             }
           });
         }
         // Cannot get minimal width of flexible scrubber and clickToPlay.
         // Rewrite to empirical value for now.
-        this.controlBar.minHeight = 40;
         this.scrubberStack.minWidth = 64;
-        this.volumeControl.minWidth = 48;
+        this.volumeStack.minWidth = 48;
         this.clickToPlay.minWidth = 48;
 
         if (this.positionDurationBox) {
-          this.durationSpan.resized = true;
+          this.positionDurationBox.resizedHandler = () => {
+            let durationWidth = this.durationSpan.hideByAdjustment ? 0 : this.durationSpan.clientWidth;
+
+            this.positionDurationBox.minWidth = this.positionDurationBox.clientWidth - durationWidth;
+          };
+
           this.positionDurationBox.resized = true;
-          this.positionDurationBox.resizedHandler = () => {
-            this.positionDurationBox.minWidth = this.positionDurationBox.clientWidth - this.durationSpan.clientWidth;
-          };
         }
 
         this.adjustControlSize();
         this.controlBar.hidden = true;
 
         // Can only update the volume controls once we've computed
         // _volumeControlWidth, since the volume slider implementation
         // depends on it.
@@ -1097,18 +1094,21 @@
         }
 
         if (immediate) {
           element.setAttribute("immediate", true);
         } else {
           element.removeAttribute("immediate");
         }
 
-        if (fadeIn && !(element.isAdjustableControl && element.hideByAdjustment)) {
-          element.hidden = false;
+        if (fadeIn) {
+          // hidden state should be controlled by adjustControlSize
+          if (!(element.isAdjustableControl && element.hideByAdjustment)) {
+            element.hidden = false;
+          }
           // force style resolution, so that transition begins
           // when we remove the attribute.
           element.clientTop;
           element.removeAttribute("fadeout");
           if (element.classList.contains("controlBar")) {
             this.controlsSpacer.removeAttribute("hideCursor");
           }
         } else {
@@ -1134,16 +1134,36 @@
         }
 
         if (this.videocontrols.isTouchControls) {
           this.scrubber.dragStateChanged(false);
         }
         element.hidden = true;
       },
 
+      onVideoControlsResized() {
+        // Do not adjust again if resize event is caused by adjustControls(). Audio
+        // might be resized by adjustControls(), so we should skip this handler if
+        // current size is the same as the adjusted size.
+        let {width, height} = this.video.getBoundingClientRect();
+        if (width === this.adjustedVideoSize.width && height === this.adjustedVideoSize.height) {
+          return;
+        }
+
+        // For the controls which haven't got correct computed size yet, force
+        // them to re-cache their minWidth when the media is resized (reflow).
+        this.layoutControls.forEach(control => {
+          if (control) {
+            control.resized = control.resized || (control.minWidth === 0);
+          }
+        });
+
+        this.adjustControlSize();
+      },
+
       _triggeredByControls: false,
 
       startPlay() {
         this._triggeredByControls = true;
         this.hideClickToPlay();
         this.video.play();
       },
 
@@ -1244,17 +1264,17 @@
 
         // The play button will animate to 3x its size. This
         // shows the animation unless the video is too small
         // to show 2/3 of the animation.
         let animationScale = 2;
         let animationMinSize = this.clickToPlay.minWidth * animationScale;
 
         if (animationMinSize > videoWidth ||
-            animationMinSize > (videoHeight - this.controlBar.minHeight)) {
+            animationMinSize > (videoHeight - this.controlBarMinHeight)) {
           this.clickToPlay.setAttribute("immediate", "true");
           this.clickToPlay.hidden = true;
         } else {
           this.clickToPlay.removeAttribute("immediate");
         }
         this.clickToPlay.setAttribute("fadeout", "true");
         this.controlsSpacer.setAttribute("fadeout", "true");
       },
@@ -1432,29 +1452,29 @@
             return true;
           }
         }
 
         return false;
       },
 
       setClosedCaptionButtonState() {
+        this.adjustControlSize();
+
         if (!this.isClosedCaptionAvailable) {
-          this.closedCaptionButton.setAttribute("hidden", "true");
           return;
         }
 
         this.closedCaptionButton.removeAttribute("hidden");
 
         if (this.isClosedCaptionOn()) {
           this.closedCaptionButton.setAttribute("enabled", "true");
         } else {
           this.closedCaptionButton.removeAttribute("enabled");
         }
-        this.adjustControlSize();
 
         let ttItems = this.textTrackList.childNodes;
 
         for (let tti of ttItems) {
           const idx = +tti.getAttribute("index");
 
           if (idx == this.currentTextTrackIndex) {
             tti.setAttribute("on", "true");
@@ -1604,18 +1624,42 @@
       },
 
       get isTopLevelSyntheticDocument() {
         let doc = this.video.ownerDocument;
         let win = doc.defaultView;
         return doc.mozSyntheticDocument && win === win.top;
       },
 
+      controlBarMinHeight : 40,
+      adjustedVideoSize : {},
       adjustControlSize() {
-        if (!this.controlBar.minWidth || this.videocontrols.isTouchControls) {
+        if (this.videocontrols.isTouchControls) {
+          return;
+        }
+
+        let controlHidden = this.isControlBarHidden;
+
+        if (this.layoutControls.some(control => control.resized)) {
+          this.controlBar.hidden = false;
+
+          for (let control of this.layoutControls) {
+            if (control.resized && !control.hideByAdjustment) {
+              control.resizedHandler();
+              control.resized = false;
+            }
+          }
+
+          this.controlBar.hidden = controlHidden;
+        }
+
+        // Check minWidth of controlBar before adjusting. If the layout information
+        // isn't ready yet, the minWidth of controlBar would be undefined or 0, so early
+        // return to avoid invalid adjustment.
+        if (!this.controlBar.minWidth) {
           return;
         }
 
         let videoWidth = this.video.clientWidth;
         let videoHeight = this.video.clientHeight;
         const minControlBarPaddingWidth = 18;
 
         // Hide and show control in order.
@@ -1639,37 +1683,36 @@
         if (this.muteButton.hasAttribute("noAudio")) {
           this.volumeStack.isWanted = false;
         }
 
         let widthUsed = minControlBarPaddingWidth;
         let preventAppendControl = false;
 
         for (let control of prioritizedControls) {
-          if (!this.isControlBarHidden && control.resized && !control.hideByAdjustment) {
-            control.resizedHandler();
-            control.resized = false;
-          }
-
           if (!control.isWanted) {
             control.hideByAdjustment = true;
             continue;
           }
 
           control.hideByAdjustment = preventAppendControl ||
           widthUsed + control.minWidth > videoWidth;
 
           if (control.hideByAdjustment) {
             preventAppendControl = true;
           } else {
             widthUsed += control.minWidth;
           }
         }
 
-        if (videoHeight < this.controlBar.minHeight ||
+        if (this.durationSpan.hideByAdjustment) {
+          this.positionDurationBox.resized = true;
+        }
+
+        if (videoHeight < this.controlBarMinHeight ||
             widthUsed === minControlBarPaddingWidth) {
           this.controlBar.setAttribute("size", "hidden");
           this.controlBar.hideByAdjustment = true;
         } else {
           this.controlBar.removeAttribute("size");
           this.controlBar.hideByAdjustment = false;
         }
 
@@ -1680,25 +1723,27 @@
 
         // Adjust clickToPlayButton size.
         const minVideoSideLength = Math.min(videoWidth, videoHeight);
         const clickToPlayViewRatio = 0.15;
         const clickToPlayScaledSize = Math.max(
         this.clickToPlay.minWidth, minVideoSideLength * clickToPlayViewRatio);
 
         if (clickToPlayScaledSize >= videoWidth ||
-            (clickToPlayScaledSize + this.controlBar.minHeight / 2 >= videoHeight / 2 )) {
+           (clickToPlayScaledSize + this.controlBarMinHeight / 2 >= videoHeight / 2 )) {
           this.clickToPlay.hideByAdjustment = true;
         } else {
           if (this.clickToPlay.hidden && !this.video.played.length && this.video.paused) {
             this.clickToPlay.hideByAdjustment = false;
           }
           this.clickToPlay.style.width = `${clickToPlayScaledSize}px`;
           this.clickToPlay.style.height = `${clickToPlayScaledSize}px`;
         }
+        // Record new size after adjustment
+        this.adjustedVideoSize = this.video.getBoundingClientRect();
       },
 
       init(binding) {
         this.video = binding.parentNode;
         this.videocontrols = binding;
 
         this.controlsContainer    = document.getAnonymousElementByAttribute(binding, "anonid", "controlsContainer");
         this.statusIcon    = document.getAnonymousElementByAttribute(binding, "anonid", "statusIcon");
@@ -1722,16 +1767,24 @@
         this.fullscreenButton   = document.getAnonymousElementByAttribute(binding, "anonid", "fullscreenButton");
         this.closedCaptionButton = document.getAnonymousElementByAttribute(binding, "anonid", "closedCaptionButton");
         this.textTrackList = document.getAnonymousElementByAttribute(binding, "anonid", "textTrackList");
 
         if (this.positionDurationBox) {
           this.durationSpan = this.positionDurationBox.getElementsByTagName("span")[0];
         }
 
+        this.layoutControls = [
+          ...this.controlBar.children,
+          this.durationSpan,
+          this.controlBar,
+          this.clickToPlay
+        ];
+
+
         // XXX controlsContainer is a desktop only element. To determine whether
         // isTouchControls or not during the whole initialization process, get
         // this state overridden here.
         this.videocontrols.isTouchControls = !this.controlsContainer;
         this.isAudioOnly = (this.video instanceof HTMLAudioElement);
         this.setupInitialState();
         this.setupNewLoadState();
         this.initTextTracks();
@@ -1760,17 +1813,17 @@
         addListener(this.muteButton, "click", this.toggleMute);
         addListener(this.closedCaptionButton, "click", this.toggleClosedCaption);
         addListener(this.fullscreenButton, "click", this.toggleFullscreen);
         addListener(this.playButton, "click", this.clickToPlayClickHandler);
         addListener(this.clickToPlay, "click", this.clickToPlayClickHandler);
         addListener(this.controlsSpacer, "click", this.clickToPlayClickHandler);
         addListener(this.controlsSpacer, "dblclick", this.toggleFullscreen);
 
-        addListener(this.videocontrols, "resizevideocontrols", this.adjustControlSize);
+        addListener(this.videocontrols, "resizevideocontrols", this.onVideoControlsResized);
         addListener(this.videocontrols, "transitionend", this.onTransitionEnd);
         addListener(this.video.ownerDocument, "mozfullscreenchange", this.onFullscreenChange);
         addListener(this.controlBar, "transitionend", this.onControlBarTransitioned);
         addListener(this.video.ownerDocument, "fullscreenchange", this.onFullscreenChange);
         addListener(this.video, "keypress", this.keyHandler);
 
         addListener(this.videocontrols, "dragstart", function(event) {
           event.preventDefault(); // prevent dragging of controls image (bug 517114)