Bug 1531101 - Add strings, styles and mark-up for Picture-in-Picture message for a video with controls. r=jaws
☠☠ backed out by b9b0378c5e0b ☠ ☠
authorMike Conley <mconley@mozilla.com>
Mon, 11 Mar 2019 19:41:10 +0000
changeset 524444 44d96bcc51f75d7a1a8c44d60dd7dc2131348c56
parent 524443 4a4b44c3481a20676eaf16deabb8e32f18bc8bcf
child 524445 10e9bbf2d1df3b261a86356d3e9e15da962a9568
push id2032
push userffxbld-merge
push dateMon, 13 May 2019 09:36:57 +0000
treeherdermozilla-release@455c1065dcbe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1531101
milestone67.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 1531101 - Add strings, styles and mark-up for Picture-in-Picture message for a video with controls. r=jaws Differential Revision: https://phabricator.services.mozilla.com/D22164
toolkit/content/widgets/videocontrols.js
toolkit/locales/en-US/chrome/global/videocontrols.dtd
toolkit/themes/shared/jar.inc.mn
toolkit/themes/shared/media/pictureinpicture.svg
toolkit/themes/shared/media/videocontrols.css
--- a/toolkit/content/widgets/videocontrols.js
+++ b/toolkit/content/widgets/videocontrols.js
@@ -33,29 +33,38 @@ this.VideoControlsWidget = class {
    */
   onchange() {
     this.switchImpl();
   }
 
   /*
    * Actually switch the implementation.
    * - With "controls" set, the VideoControlsImplWidget controls should load.
-   * - Without it, on mobile, the NoControlsImplWidget should load, so
+   * - Without it, on mobile, the NoControlsMobileImplWidget should load, so
    *   the user could see the click-to-play button when the video/audio is blocked.
+   * - Without it, on desktop, the NoControlsPictureInPictureImpleWidget should load
+   *   if the video is being viewed in Picture-in-Picture.
    */
   switchImpl() {
     let newImpl;
     if (this.element.controls) {
       newImpl = VideoControlsImplWidget;
     } else if (this.isMobile) {
-      newImpl = NoControlsImplWidget;
+      newImpl = NoControlsMobileImplWidget;
+    } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) {
+      newImpl = NoControlsPictureInPictureImplWidget;
     }
-    // Skip if we are asked to load the same implementation.
-    // This can happen if the property is set again w/o value change.
-    if (this.impl && this.impl.constructor == newImpl) {
+
+    // Skip if we are asked to load the same implementation, and
+    // the underlying element state hasn't changed in ways that we
+    // care about. This can happen if the property is set again
+    // without a value change.
+    if (this.impl &&
+        this.impl.constructor == newImpl &&
+        this.impl.elementStateMatches(this.element)) {
       return;
     }
     if (this.impl) {
       this.impl.destructor();
       this.shadowRoot.firstChild.remove();
     }
     if (newImpl) {
       this.impl = new newImpl(this.shadowRoot);
@@ -68,16 +77,20 @@ this.VideoControlsWidget = class {
   destructor() {
     if (!this.impl) {
       return;
     }
     this.impl.destructor();
     this.shadowRoot.firstChild.remove();
     delete this.impl;
   }
+
+  static isPictureInPictureVideo(someVideo) {
+    return someVideo.isCloningElementVisually;
+  }
 };
 
 this.VideoControlsImplWidget = class {
   constructor(shadowRoot) {
     this.shadowRoot = shadowRoot;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
     this.window = this.document.defaultView;
@@ -100,16 +113,17 @@ this.VideoControlsImplWidget = class {
       progressBar: null,
       bufferBar: null,
       statusOverlay: null,
       controlsSpacer: null,
       clickToPlay: null,
       controlsOverlay: null,
       fullscreenButton: null,
       layoutControls: null,
+      isShowingPictureInPictureMessage: false,
 
       textTracksCount: 0,
       videoEvents: ["play", "pause", "ended", "volumechange", "loadeddata",
                     "loadstart", "timeupdate", "progress",
                     "playing", "waiting", "canplay", "canplaythrough",
                     "seeking", "seeked", "emptied", "loadedmetadata",
                     "error", "suspend", "stalled",
                     "mozvideoonlyseekbegin", "mozvideoonlyseekcompleted"],
@@ -242,16 +256,18 @@ this.VideoControlsImplWidget = class {
         }
 
         // Set the current status icon.
         if (this.hasError()) {
           this.startFadeOut(this.clickToPlay, true);
           this.statusIcon.setAttribute("type", "error");
           this.updateErrorText();
           this.setupStatusFader(true);
+        } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) {
+          this.setShowPictureInPictureMessage(true);
         }
 
         let adjustableControls = [
           ...this.prioritizedControls,
           this.controlBar,
           this.clickToPlay,
         ];
 
@@ -360,17 +376,18 @@ this.VideoControlsImplWidget = class {
         // For videos with |autoplay| set, we'll leave the controls initially hidden,
         // so that they don't get in the way of the playing video. Otherwise we'll
         // go ahead and reveal the controls now, so they're an obvious user cue.
         var shouldShow = !this.dynamicControls ||
                          (this.video.paused &&
                          !this.video.autoplay);
         // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107.
         let shouldClickToPlayShow = shouldShow && !this.isAudioOnly &&
-                                    this.video.currentTime == 0 && !this.hasError();
+                                    this.video.currentTime == 0 && !this.hasError() &&
+                                    !this.isShowingPictureInPictureMessage;
         this.startFade(this.clickToPlay, shouldClickToPlayShow, true);
         this.startFade(this.controlBar, shouldShow, true);
       },
 
       get dynamicControls() {
         // Don't fade controls for <audio> elements.
         var enabled = !this.isAudioOnly;
 
@@ -745,16 +762,26 @@ this.VideoControlsImplWidget = class {
         // do this intentionally to work around requires-user-interaction to
         // play restrictions, and we don't want to display a debug message
         // if that's the case.
         return this.video.error != null ||
                (this.video.networkState == this.video.NETWORK_NO_SOURCE &&
                this.hasSources());
       },
 
+      setShowPictureInPictureMessage(showMessage) {
+        if (showMessage) {
+          this.pictureInPictureOverlay.removeAttribute("hidden");
+        } else {
+          this.pictureInPictureOverlay.setAttribute("hidden", true);
+        }
+
+        this.isShowingPictureInPictureMessage = showMessage;
+      },
+
       hasSources() {
         if (this.video.hasAttribute("src") &&
             this.video.getAttribute("src") !== "") {
           return true;
         }
         for (var child = this.video.firstChild;
              child !== null;
              child = child.nextElementSibling) {
@@ -1936,16 +1963,17 @@ this.VideoControlsImplWidget = class {
         this.bufferBar = this.shadowRoot.getElementById("bufferBar");
         this.scrubberStack = this.shadowRoot.getElementById("scrubberStack");
         this.scrubber = this.shadowRoot.getElementById("scrubber");
         this.durationLabel = this.shadowRoot.getElementById("durationLabel");
         this.positionLabel = this.shadowRoot.getElementById("positionLabel");
         this.positionDurationBox = this.shadowRoot.getElementById("positionDurationBox");
         this.statusOverlay = this.shadowRoot.getElementById("statusOverlay");
         this.controlsOverlay = this.shadowRoot.getElementById("controlsOverlay");
+        this.pictureInPictureOverlay = this.shadowRoot.getElementById("pictureInPictureOverlay");
         this.controlsSpacer = this.shadowRoot.getElementById("controlsSpacer");
         this.clickToPlay = this.shadowRoot.getElementById("clickToPlay");
         this.fullscreenButton = this.shadowRoot.getElementById("fullscreenButton");
         this.castingButton = this.shadowRoot.getElementById("castingButton");
         this.closedCaptionButton = this.shadowRoot.getElementById("closedCaptionButton");
         this.textTrackList = this.shadowRoot.getElementById("textTrackList");
 
         if (this.positionDurationBox) {
@@ -2209,16 +2237,21 @@ this.VideoControlsImplWidget = class {
             <span class="statusLabel" id="statusErrorAborted">&error.aborted;</span>
             <span class="statusLabel" id="statusErrorNetwork">&error.network;</span>
             <span class="statusLabel" id="statusErrorDecode">&error.decode;</span>
             <span class="statusLabel" id="statusErrorSrcNotSupported">&error.srcNotSupported;</span>
             <span class="statusLabel" id="statusErrorNoSource">&error.noSource2;</span>
             <span class="statusLabel" id="statusErrorGeneric">&error.generic;</span>
           </div>
 
+          <div id="pictureInPictureOverlay" class="pictureInPictureOverlay stackItem" status="pictureInPicture" hidden="true">
+            <div class="statusIcon" type="pictureInPicture"></div>
+            <span class="statusLabel" id="statusPictureInPicture">&status.pictureInPicture;</span>
+          </div>
+
           <div id="controlsOverlay" class="controlsOverlay stackItem" role="none">
             <div class="controlsSpacerStack">
               <div id="controlsSpacer" class="controlsSpacer stackItem" role="none"></div>
               <div id="clickToPlay" class="clickToPlay" hidden="true"></div>
             </div>
 
             <div id="controlBar" class="controlBar" role="none" hidden="true">
               <button id="playButton"
@@ -2260,16 +2293,21 @@ this.VideoControlsImplWidget = class {
             </div>
             <div id="textTrackList" class="textTrackList" hidden="true" offlabel="&closedCaption.off;"></div>
           </div>
         </div>
       </div>`, "application/xml");
     this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
   }
 
+  elementStateMatches(element) {
+    let elementInPiP = VideoControlsWidget.isPictureInPictureVideo(element);
+    return this.isShowingPictureInPictureMessage == elementInPiP;
+  }
+
   destructor() {
     this.Utils.terminate();
     this.TouchUtils.terminate();
     this.Utils.updateOrientationState(false);
   }
 
   _setupEventListeners() {
     this.shadowRoot.firstChild.addEventListener("mouseover", event => {
@@ -2287,17 +2325,17 @@ this.VideoControlsImplWidget = class {
     this.shadowRoot.firstChild.addEventListener("mousemove", event => {
       if (!this.Utils.isTouchControls) {
         this.Utils.onMouseMove(event);
       }
     });
   }
 };
 
-this.NoControlsImplWidget = class {
+this.NoControlsMobileImplWidget = class {
   constructor(shadowRoot) {
     this.shadowRoot = shadowRoot;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
     this.window = this.document.defaultView;
   }
 
   onsetup() {
@@ -2392,16 +2430,20 @@ this.NoControlsImplWidget = class {
           });
         }
       },
     };
     this.Utils.init(this.shadowRoot);
     this.Utils.video.dispatchEvent(new this.window.CustomEvent("MozNoControlsVideoBindingAttached"));
   }
 
+  elementStateMatches(element) {
+    return true;
+  }
+
   destructor() {
     this.Utils.terminate();
   }
 
   generateContent() {
     /*
      * Pass the markup through XML parser purely for the reason of loading the localization DTD.
      * Remove it when migrate to Fluent.
@@ -2419,8 +2461,51 @@ this.NoControlsImplWidget = class {
               <div id="clickToPlay" class="clickToPlay"></div>
             </div>
           </div>
         </div>
       </div>`, "application/xml");
     this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
   }
 };
+
+this.NoControlsPictureInPictureImplWidget = class {
+  constructor(shadowRoot) {
+    this.shadowRoot = shadowRoot;
+    this.element = shadowRoot.host;
+    this.document = this.element.ownerDocument;
+    this.window = this.document.defaultView;
+  }
+
+  onsetup() {
+    this.generateContent();
+  }
+
+  elementStateMatches(element) {
+    return true;
+  }
+
+  destructor() {
+  }
+
+  generateContent() {
+    /*
+     * Pass the markup through XML parser purely for the reason of loading the localization DTD.
+     * Remove it when migrate to Fluent.
+     */
+    const parser = new this.window.DOMParser();
+    let parserDoc = parser.parseFromString(`<!DOCTYPE bindings [
+      <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd">
+      %videocontrolsDTD;
+      ]>
+      <div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none">
+        <link rel="stylesheet" type="text/css" href="chrome://global/skin/media/videocontrols.css" />
+        <div id="controlsContainer" class="controlsContainer" role="none">
+          <div class="pictureInPictureOverlay stackItem" status="pictureInPicture">
+            <div id="statusIcon" class="statusIcon" type="pictureInPicture"></div>
+            <span class="statusLabel" id="statusPictureInPicture">&status.pictureInPicture;</span>
+          </div>
+          <div class="controlsOverlay stackItem"></div>
+        </div>
+      </div>`, "application/xml");
+    this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
+  }
+};
--- a/toolkit/locales/en-US/chrome/global/videocontrols.dtd
+++ b/toolkit/locales/en-US/chrome/global/videocontrols.dtd
@@ -29,16 +29,18 @@
 
 <!ENTITY error.aborted "Video loading stopped.">
 <!ENTITY error.network "Video playback aborted due to a network error.">
 <!ENTITY error.decode "Video can’t be played because the file is corrupt.">
 <!ENTITY error.srcNotSupported "Video format or MIME type is not supported.">
 <!ENTITY error.noSource2 "No video with supported format and MIME type found.">
 <!ENTITY error.generic "Video playback aborted due to an unknown error.">
 
+<!ENTITY status.pictureInPicture "This video is playing in Picture-in-Picture mode.">
+
 <!-- LOCALIZATION NOTE (scrubberScale.nameFormat): the #1 string is the current
 media position, and the #2 string is the total duration. For example, when at
 the 5 minute mark in a 6 hour long video, #1 would be "5:00" and #2 would be
 "6:00:00", result string would be "5:00 of 6:00:00 elapsed".
 -->
 <!ENTITY scrubberScale.nameFormat "#1 of #2 elapsed">
 
 <!-- LOCALIZATION NOTE (positionAndDuration.nameFormat): the #1 string is the current
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -102,10 +102,11 @@ toolkit.jar:
   skin/classic/global/plugins/plugin-blocked.svg            (../../shared/plugins/plugin-blocked.svg)
   skin/classic/global/plugins/pluginGeneric.svg             (../../shared/extensions/category-plugins.svg)
   skin/classic/global/plugins/pluginProblem.css             (../../shared/plugins/pluginProblem.css)
   skin/classic/global/plugins/contentPluginBlocked.png      (../../shared/plugins/contentPluginBlocked.png)
   skin/classic/global/plugins/contentPluginCrashed.png      (../../shared/plugins/contentPluginCrashed.png)
   skin/classic/global/plugins/contentPluginStripe.png       (../../shared/plugins/contentPluginStripe.png)
 #ifdef NIGHTLY_BUILD
   skin/classic/global/pictureinpicture/player.css           (../../shared/pictureinpicture/player.css)
+  skin/classic/global/media/pictureinpicture.svg            (../../shared/media/pictureinpicture.svg)
 #endif
 
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/media/pictureinpicture.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" version="1.1">
+  <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+    <path stroke="context-stroke" d="M0.5 0.5h13v13h-13z"/>
+    <path fill="context-fill" d="M8 10h8v6h-8z"/>
+    <path stroke="context-stroke" d="M0.5 5.5h7v-5h-5.5a1.5 1.5 0 0 0 -1.5 1.5v3.5z"/>
+  </g>
+</svg>
\ No newline at end of file
--- a/toolkit/themes/shared/media/videocontrols.css
+++ b/toolkit/themes/shared/media/videocontrols.css
@@ -406,21 +406,36 @@
 .controlsContainer:not(.mobile) .statusIcon[type="throbber"] {
   background: url(chrome://global/skin/media/throbber.png) no-repeat center;
 }
 
 .controlsContainer:not(.mobile) .statusIcon[type="throbber"][stalled] {
   background: url(chrome://global/skin/media/stalled.png) no-repeat center;
 }
 
+.statusIcon[type="error"],
+.statusIcon[type="pictureInPicture"] {
+  background-size: contain;
+  background-repeat: no-repeat;
+  background-position: center;
+}
+
 .statusIcon[type="error"] {
   min-width: 70px;
   min-height: 60px;
-  background: url(chrome://global/skin/media/error.png) no-repeat center;
-  background-size: contain;
+  background-image: url(chrome://global/skin/media/error.png);
+}
+
+.statusIcon[type="pictureInPicture"] {
+  min-width: 84px;
+  min-height: 84px;
+  background-image: url(chrome://global/skin/media/pictureinpicture.svg);
+  -moz-context-properties: fill, stroke;
+  fill: #fff;
+  stroke: #fff;
 }
 
 /* Overlay Play button */
 .clickToPlay {
   min-width: var(--clickToPlay-size);
   min-height: var(--clickToPlay-size);
   border-radius: 50%;
   background-image: url(chrome://global/skin/media/playButton.svg);
@@ -448,16 +463,25 @@
   display: none;
 }
 
 .statusOverlay[fadeout],
 .statusOverlay[error] + .controlsOverlay > .controlsSpacerStack {
   opacity: 0;
 }
 
+.pictureInPictureOverlay {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  opacity: 1;
+  background-color: rgb(12, 12, 13);
+}
+
 /* Status description formatting */
 .statusLabel {
   padding: 0 10px;
   text-align: center;
   font: message-box;
   font-size: 14px;
   color: #ffffff;
 }
@@ -466,17 +490,18 @@
   display: none;
 }
 
 [status="errorAborted"]         > [id="statusErrorAborted"],
 [status="errorNetwork"]         > [id="statusErrorNetwork"],
 [status="errorDecode"]          > [id="statusErrorDecode"],
 [status="errorSrcNotSupported"] > [id="statusErrorSrcNotSupported"],
 [status="errorNoSource"]        > [id="statusErrorNoSource"],
-[status="errorGeneric"]         > [id="statusErrorGeneric"] {
+[status="errorGeneric"]         > [id="statusErrorGeneric"],
+[status="pictureInPicture"]     > [id="statusPictureInPicture"] {
   display: inline;
 }
 
 %ifdef XP_WIN
 @media (-moz-windows-default-theme: 0) {
   .controlsSpacer,
   .clickToPlay {
     background-color: transparent;