Bug 1653663 - Add experimental Picture-in-Picture toggle variations. r=mstriemer,flod
authorMike Conley <mconley@mozilla.com>
Wed, 22 Jul 2020 16:02:13 +0000
changeset 609681 c2260bc5bbcffb9aaf56f858bdc98e551c607ea2
parent 609680 6355e6901d9896d8e85e45fa95b37397283e50c4
child 609682 02d2ee1875b0510e89e63109ef869ee1aa8f5404
push id2389
push userffxbld-merge
push dateMon, 17 Aug 2020 10:06:13 +0000
treeherdermozilla-release@7ca9da01bf57 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmstriemer, flod
bugs1653663
milestone80.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 1653663 - Add experimental Picture-in-Picture toggle variations. r=mstriemer,flod media.videocontrols.picture-in-picture.video-toggle.mode controls the toggle variation. Valid values are -1 (default, what we currently ship), 1 and 2. media.videocontrols.picture-in-picture.video-toggle.position controls the toggle variation position. Valid values are "left" and "right" (default). Differential Revision: https://phabricator.services.mozilla.com/D84032
modules/libpref/init/all.js
toolkit/actors/PictureInPictureChild.jsm
toolkit/actors/UAWidgetsChild.jsm
toolkit/components/pictureinpicture/PictureInPicture.jsm
toolkit/content/widgets/videocontrols.js
toolkit/locales/en-US/chrome/global/videocontrols.dtd
toolkit/themes/shared/jar.inc.mn
toolkit/themes/shared/media/pictureinpicture-mode-1.css
toolkit/themes/shared/media/pictureinpicture-mode-2.css
toolkit/themes/shared/media/videocontrols.css
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -410,16 +410,19 @@ pref("media.decoder-doctor.decode-warnin
 pref("media.decoder-doctor.verbose", false);
 // URL to report decode issues
 pref("media.decoder-doctor.new-issue-endpoint", "https://webcompat.com/issues/new");
 
 pref("media.videocontrols.picture-in-picture.enabled", false);
 pref("media.videocontrols.picture-in-picture.video-toggle.enabled", false);
 pref("media.videocontrols.picture-in-picture.video-toggle.always-show", false);
 pref("media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 45);
+pref("media.videocontrols.picture-in-picture.video-toggle.mode", -1);
+pref("media.videocontrols.picture-in-picture.video-toggle.position", "right");
+pref("media.videocontrols.picture-in-picture.video-toggle.has-used", false);
 
 #ifdef MOZ_WEBRTC
   pref("media.navigator.video.enabled", true);
   pref("media.navigator.video.default_fps",30);
   pref("media.navigator.video.use_remb", true);
   pref("media.navigator.video.use_transport_cc", true);
   pref("media.peerconnection.video.use_rtx", true);
   pref("media.peerconnection.video.use_rtx.blocklist", "");
--- a/toolkit/actors/PictureInPictureChild.jsm
+++ b/toolkit/actors/PictureInPictureChild.jsm
@@ -21,25 +21,32 @@ ChromeUtils.defineModuleGetter(
   "TOGGLE_POLICIES",
   "resource://gre/modules/PictureInPictureTogglePolicy.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "TOGGLE_POLICY_STRINGS",
   "resource://gre/modules/PictureInPictureTogglePolicy.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "Rect",
+  "resource://gre/modules/Geometry.jsm"
+);
 
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
 const TOGGLE_ENABLED_PREF =
   "media.videocontrols.picture-in-picture.video-toggle.enabled";
 const TOGGLE_TESTING_PREF =
   "media.videocontrols.picture-in-picture.video-toggle.testing";
+const TOGGLE_EXPERIMENTAL_MODE_PREF =
+  "media.videocontrols.picture-in-picture.video-toggle.mode";
 const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
 const TOGGLE_HIDING_TIMEOUT_MS = 2000;
 
 // A weak reference to the most recent <video> in this content
 // process that is being viewed in Picture-in-Picture.
 var gWeakVideo = null;
 // A weak reference to the content window of the most recent
 // Picture-in-Picture window for this content process.
@@ -68,44 +75,66 @@ class PictureInPictureToggleChild extend
     // We need to maintain some state about various things related to the
     // Picture-in-Picture toggles - however, for now, the same
     // PictureInPictureToggleChild might be re-used for different documents.
     // We keep the state stashed inside of this WeakMap, keyed on the document
     // itself.
     this.weakDocStates = new WeakMap();
     this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
     this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
+    this.experimentalToggle =
+      Services.prefs.getIntPref(TOGGLE_EXPERIMENTAL_MODE_PREF, -1) != -1;
 
     // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
     // directly, so we create a new function here instead to act as our
     // nsIObserver, which forwards the notification to the observe method.
     this.observerFunction = (subject, topic, data) => {
       this.observe(subject, topic, data);
     };
     Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
+    Services.prefs.addObserver(
+      TOGGLE_EXPERIMENTAL_MODE_PREF,
+      this.observerFunction
+    );
   }
 
   willDestroy() {
     this.stopTrackingMouseOverVideos();
     Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
+    Services.prefs.removeObserver(
+      TOGGLE_EXPERIMENTAL_MODE_PREF,
+      this.observerFunction
+    );
   }
 
   observe(subject, topic, data) {
-    if (topic == "nsPref:changed" && data == TOGGLE_ENABLED_PREF) {
-      this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
+    if (topic != "nsPref:changed") {
+      return;
+    }
+
+    switch (data) {
+      case TOGGLE_ENABLED_PREF: {
+        this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
 
-      if (this.toggleEnabled) {
-        // We have enabled the Picture-in-Picture toggle, so we need to make
-        // sure we register all of the videos that might already be on the page.
-        this.contentWindow.requestIdleCallback(() => {
-          let videos = this.document.querySelectorAll("video");
-          for (let video of videos) {
-            this.registerVideo(video);
-          }
-        });
+        if (this.toggleEnabled) {
+          // We have enabled the Picture-in-Picture toggle, so we need to make
+          // sure we register all of the videos that might already be on the page.
+          this.contentWindow.requestIdleCallback(() => {
+            let videos = this.document.querySelectorAll("video");
+            for (let video of videos) {
+              this.registerVideo(video);
+            }
+          });
+        }
+        break;
+      }
+      case TOGGLE_EXPERIMENTAL_MODE_PREF: {
+        this.experimentalToggle =
+          Services.prefs.getIntPref(TOGGLE_EXPERIMENTAL_MODE_PREF, -1) != -1;
+        break;
       }
     }
   }
 
   /**
    * Returns the state for the current document referred to via
    * this.document. If no such state exists, creates it, stores it
    * and returns it.
@@ -526,17 +555,17 @@ class PictureInPictureToggleChild extend
       true,
       false,
       true /* aOnlyVisible */
     );
     if (!Array.from(elements).includes(video)) {
       return;
     }
 
-    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
+    let toggle = this.getToggleElement(shadowRoot);
     if (this.isMouseOverToggle(toggle, event)) {
       let state = this.docState;
       state.isClickingToggle = true;
       state.clickedElement = Cu.getWeakReference(event.originalTarget);
       event.stopImmediatePropagation();
 
       Services.telemetry.keyedScalarAdd(
         "pictureinpicture.opened_method",
@@ -697,17 +726,17 @@ class PictureInPictureToggleChild extend
         // if there was one.
         this.onMouseLeaveVideo(oldOverVideo);
       }
 
       return;
     }
 
     let state = this.docState;
-    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
+    let toggle = this.getToggleElement(shadowRoot);
     let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
 
     if (!state.hasCheckedPolicy) {
       // We cache the matchers process-wide. We'll skip this while running tests to make that
       // easier.
       let toggleOverrides = this.toggleTesting
         ? PictureInPictureToggleChild.getToggleOverrides()
         : gToggleOverrides;
@@ -788,17 +817,17 @@ class PictureInPictureToggleChild extend
    * @param {Element} video The video that the mouse left.
    */
   onMouseLeaveVideo(video) {
     let state = this.docState;
     let shadowRoot = video.openOrClosedShadowRoot;
 
     if (shadowRoot) {
       let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
-      let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
+      let toggle = this.getToggleElement(shadowRoot);
       controlsOverlay.classList.remove("hovering");
       toggle.classList.remove("hovering");
     }
 
     state.weakOverVideo = null;
 
     if (!this.toggleTesting) {
       state.hideToggleDeferredTask.disarm();
@@ -817,16 +846,34 @@ class PictureInPictureToggleChild extend
    *
    * @return {Boolean}
    */
   isMouseOverToggle(toggle, event) {
     let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(
       toggle
     );
 
+    if (this.experimentalToggle) {
+      // The way the experimental toggles are currently implemented with
+      // absolute positioning, the root toggle element bounds don't actually
+      // contain all of the toggle child element bounds. Until we find a way to
+      // sort that out, we workaround the issue by having each clickable child
+      // elements of the toggle have a clicklable class, and then compute the
+      // smallest rect that contains all of their bounding rects and use that
+      // as the hitbox.
+      toggleRect = Rect.fromRect(toggleRect);
+      let clickableChildren = toggle.querySelectorAll(".clickable");
+      for (let child of clickableChildren) {
+        let childRect = Rect.fromRect(
+          child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child)
+        );
+        toggleRect.expandToContain(childRect);
+      }
+    }
+
     // If the toggle has no dimensions, we're definitely not over it.
     if (!toggleRect.width || !toggleRect.height) {
       return false;
     }
 
     let { clientX, clientY } = event;
 
     return (
@@ -850,30 +897,44 @@ class PictureInPictureToggleChild extend
       return;
     }
 
     let shadowRoot = video.openOrClosedShadowRoot;
     if (!shadowRoot) {
       return;
     }
 
-    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
+    let toggle = this.getToggleElement(shadowRoot);
     if (this.isMouseOverToggle(toggle, event)) {
       event.stopImmediatePropagation();
       event.preventDefault();
 
       this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
         screenX: event.screenX,
         screenY: event.screenY,
         mozInputSource: event.mozInputSource,
       });
     }
   }
 
   /**
+   * Returns the appropriate root element for the Picture-in-Picture toggle,
+   * depending on whether or not we're using the experimental toggle preference.
+   *
+   * @param {Element} shadowRoot The shadowRoot of the video element.
+   * @returns {Element} The toggle element.
+   */
+  getToggleElement(shadowRoot) {
+    if (!this.experimentalToggle) {
+      return shadowRoot.getElementById("pictureInPictureToggleButton");
+    }
+    return shadowRoot.getElementById("pictureInPictureToggleExperiment");
+  }
+
+  /**
    * This is a test-only function that returns true if a video is being tracked
    * for mouseover events after having intersected the viewport.
    */
   static isTracking(video) {
     return gWeakIntersectingVideosForTesting.has(video);
   }
 
   /**
--- a/toolkit/actors/UAWidgetsChild.jsm
+++ b/toolkit/actors/UAWidgetsChild.jsm
@@ -72,16 +72,19 @@ class UAWidgetsChild extends JSWindowAct
       case "video":
       case "audio":
         uri = "chrome://global/content/elements/videocontrols.js";
         widgetName = "VideoControlsWidget";
         prefKeys = [
           "media.videocontrols.picture-in-picture.video-toggle.enabled",
           "media.videocontrols.picture-in-picture.video-toggle.always-show",
           "media.videocontrols.picture-in-picture.video-toggle.min-video-secs",
+          "media.videocontrols.picture-in-picture.video-toggle.mode",
+          "media.videocontrols.picture-in-picture.video-toggle.position",
+          "media.videocontrols.picture-in-picture.video-toggle.has-used",
         ];
         break;
       case "input":
         uri = "chrome://global/content/elements/datetimebox.js";
         widgetName = "DateTimeBoxWidget";
         break;
       case "embed":
       case "object":
--- a/toolkit/components/pictureinpicture/PictureInPicture.jsm
+++ b/toolkit/components/pictureinpicture/PictureInPicture.jsm
@@ -236,16 +236,21 @@ var PictureInPicture = {
     win.setIsMutedState(videoData.isMuted);
 
     // set attribute which shows pip icon in tab
     let tab = parentWin.gBrowser.getTabForBrowser(browser);
     tab.setAttribute("pictureinpicture", true);
 
     win.setupPlayer(gNextWindowID.toString(), browser);
     gNextWindowID++;
+
+    Services.prefs.setBoolPref(
+      "media.videocontrols.picture-in-picture.video-toggle.has-used",
+      true
+    );
   },
 
   /**
    * unload event has been called in player.js, cleanup our preserved
    * browser object.
    */
   unload(window) {
     TelemetryStopwatch.finish(
--- a/toolkit/content/widgets/videocontrols.js
+++ b/toolkit/content/widgets/videocontrols.js
@@ -173,16 +173,85 @@ this.VideoControlsWidget = class {
     // Picture-in-Picture capability on <video> elements that have
     // srcObject != null.
     if (someVideo.srcObject) {
       return false;
     }
 
     return true;
   }
+
+  /**
+   * Some variations on the Picture-in-Picture toggle are being experimented with.
+   * These variations have slightly different setup parameters from the currently
+   * shipping toggle, so this method sets up the experimental toggles in the event
+   * that they're being used. It also will enable the appropriate stylesheet for
+   * the preferred toggle experiment.
+   *
+   * @param {Object} prefs
+   *   The preferences set that was passed to the UAWidget.
+   * @param {ShadowRoot} shadowRoot
+   *   The shadowRoot of the <video> element where the video controls are.
+   * @param {Element} toggle
+   *   The toggle element.
+   * @param {Object} reflowedDimensions
+   *   An object representing the reflowed dimensions of the <video>. Properties
+   *   are:
+   *
+   *     videoWidth (Number):
+   *       The width of the video in pixels.
+   *
+   *     videoHeight (Number):
+   *       The height of the video in pixels.
+   */
+  static setupToggleExperiment(prefs, shadowRoot, toggle, reflowedDimensions) {
+    let mode = String(
+      prefs["media.videocontrols.picture-in-picture.video-toggle.mode"]
+    );
+    let videocontrols = shadowRoot.firstChild;
+    let sheets = videocontrols.querySelectorAll("link[rel='stylesheet'][mode]");
+    for (let sheet of sheets) {
+      sheet.disabled = sheet.getAttribute("mode") != mode;
+    }
+
+    // These thresholds are all in pixels
+    const SMALL_VIDEO_WIDTH_MAX = 320;
+    const MEDIUM_VIDEO_WIDTH_MAX = 720;
+
+    let isSmall = reflowedDimensions.videoWidth <= SMALL_VIDEO_WIDTH_MAX;
+    toggle.toggleAttribute("small-video", isSmall);
+    toggle.toggleAttribute(
+      "medium-video",
+      !isSmall && reflowedDimensions.videoWidth <= MEDIUM_VIDEO_WIDTH_MAX
+    );
+
+    toggle.setAttribute(
+      "position",
+      prefs["media.videocontrols.picture-in-picture.video-toggle.position"]
+    );
+    toggle.toggleAttribute(
+      "has-used",
+      prefs["media.videocontrols.picture-in-picture.video-toggle.has-used"]
+    );
+  }
+
+  /**
+   * Disables any lingering stylesheets that might still be active after
+   * we've determined that a toggle experiment should be removed.
+   *
+   * @param {ShadowRoot} shadowRoot
+   *   The shadowRoot of the <video> element where the video controls are.
+   */
+  static cleanupToggleExperiment(shadowRoot) {
+    let videocontrols = shadowRoot.firstChild;
+    let sheets = videocontrols.querySelectorAll("link[rel='stylesheet'][mode]");
+    for (let sheet of sheets) {
+      sheet.disabled = true;
+    }
+  }
 };
 
 this.VideoControlsImplWidget = class {
   constructor(shadowRoot, prefs) {
     this.shadowRoot = shadowRoot;
     this.prefs = prefs;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
@@ -543,19 +612,38 @@ this.VideoControlsImplWidget = class {
           this.pipToggleEnabled &&
           !this.isShowingPictureInPictureMessage &&
           VideoControlsWidget.shouldShowPictureInPictureToggle(
             this.prefs,
             this.video,
             this.reflowedDimensions
           )
         ) {
-          this.pictureInPictureToggleButton.removeAttribute("hidden");
+          if (
+            this.prefs[
+              "media.videocontrols.picture-in-picture.video-toggle.mode"
+            ] == -1
+          ) {
+            VideoControlsWidget.cleanupToggleExperiment(this.shadowRoot);
+            this.pictureInPictureToggleButton.removeAttribute("hidden");
+            this.pictureInPictureToggleExperiment.setAttribute("hidden", true);
+          } else {
+            this.pictureInPictureToggleButton.setAttribute("hidden", true);
+            this.pictureInPictureToggleExperiment.removeAttribute("hidden");
+            VideoControlsWidget.setupToggleExperiment(
+              this.prefs,
+              this.shadowRoot,
+              this.pictureInPictureToggleExperiment,
+              this.reflowedDimensions
+            );
+          }
         } else {
+          VideoControlsWidget.cleanupToggleExperiment(this.shadowRoot);
           this.pictureInPictureToggleButton.setAttribute("hidden", true);
+          this.pictureInPictureToggleExperiment.setAttribute("hidden", true);
         }
       },
 
       setupNewLoadState() {
         // 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 =
@@ -2308,16 +2396,19 @@ this.VideoControlsImplWidget = class {
         );
         this.textTrackList = this.shadowRoot.getElementById("textTrackList");
         this.textTrackListContainer = this.shadowRoot.getElementById(
           "textTrackListContainer"
         );
         this.pictureInPictureToggleButton = this.shadowRoot.getElementById(
           "pictureInPictureToggleButton"
         );
+        this.pictureInPictureToggleExperiment = this.shadowRoot.getElementById(
+          "pictureInPictureToggleExperiment"
+        );
 
         if (this.positionDurationBox) {
           this.durationSpan = this.positionDurationBox.getElementsByTagName(
             "span"
           )[0];
         }
 
         let isMobile = this.window.navigator.appVersion.includes("Android");
@@ -2589,16 +2680,18 @@ this.VideoControlsImplWidget = class {
     parser.forceEnableDTD();
     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" href="chrome://global/skin/media/videocontrols.css" />
+        <link rel="stylesheet" href="chrome://global/skin/media/pictureinpicture-mode-1.css" mode="1" disabled="true" />
+        <link rel="stylesheet" href="chrome://global/skin/media/pictureinpicture-mode-2.css" mode="2" disabled="true" />
         <div id="controlsContainer" class="controlsContainer" role="none">
           <div id="statusOverlay" class="statusOverlay stackItem" hidden="true">
             <div id="statusIcon" class="statusIcon"></div>
             <bdi class="statusLabel" id="errorAborted">&error.aborted;</bdi>
             <bdi class="statusLabel" id="errorNetwork">&error.network;</bdi>
             <bdi class="statusLabel" id="errorDecode">&error.decode;</bdi>
             <bdi class="statusLabel" id="errorSrcNotSupported">&error.srcNotSupported;</bdi>
             <bdi class="statusLabel" id="errorNoSource">&error.noSource2;</bdi>
@@ -2616,16 +2709,30 @@ this.VideoControlsImplWidget = class {
               <div id="clickToPlay" class="clickToPlay" hidden="true"></div>
             </div>
 
             <button id="pictureInPictureToggleButton" class="pictureInPictureToggleButton">
               <div id="pictureInPictureToggleIcon" class="pictureInPictureToggleIcon"></div>
               <span class="pictureInPictureToggleLabel">&pictureInPicture.label;</span>
             </button>
 
+            <button id="pictureInPictureToggleExperiment" class="pip-wrapper" position="left" hidden="true">
+              <div class="pip-small clickable"></div>
+              <div class="pip-expanded clickable">
+                <span class="pip-icon-label clickable">
+                  <span class="pip-icon"></span>
+                  <span class="pip-label">&pictureInPictureToggle.label;</span>
+                </span>
+                <div class="pip-explainer clickable">
+                  &pictureInPictureExplainer;
+                </div>
+              </div>
+              <div class="pip-icon clickable"></div>
+            </button>
+
             <div id="controlBar" class="controlBar" role="none" hidden="true">
               <button id="playButton"
                       class="button playButton"
                       playlabel="&playButton.playLabel;"
                       pauselabel="&playButton.pauseLabel;"
                       tabindex="-1"/>
               <div id="scrubberStack" class="scrubberStack progressContainer" role="none">
                 <div class="progressBackgroundBar stackItem" role="none">
@@ -2967,35 +3074,58 @@ this.NoControlsDesktopImplWidget = class
         if (
           this.pipToggleEnabled &&
           VideoControlsWidget.shouldShowPictureInPictureToggle(
             this.prefs,
             this.video,
             this.reflowedDimensions
           )
         ) {
-          this.pictureInPictureToggleButton.removeAttribute("hidden");
+          if (
+            this.prefs[
+              "media.videocontrols.picture-in-picture.video-toggle.mode"
+            ] == -1
+          ) {
+            VideoControlsWidget.cleanupToggleExperiment(this.shadowRoot);
+            this.pictureInPictureToggleButton.removeAttribute("hidden");
+            this.pictureInPictureToggleExperiment.setAttribute("hidden", true);
+          } else {
+            this.pictureInPictureToggleButton.setAttribute("hidden", true);
+            this.pictureInPictureToggleExperiment.removeAttribute("hidden");
+            VideoControlsWidget.setupToggleExperiment(
+              this.prefs,
+              this.shadowRoot,
+              this.pictureInPictureToggleExperiment,
+              this.reflowedDimensions
+            );
+          }
         } else {
+          VideoControlsWidget.cleanupToggleExperiment(this.shadowRoot);
           this.pictureInPictureToggleButton.setAttribute("hidden", true);
+          this.pictureInPictureToggleExperiment.setAttribute("hidden", true);
         }
       },
 
       init(shadowRoot, prefs) {
         this.shadowRoot = shadowRoot;
         this.prefs = prefs;
         this.video = shadowRoot.host;
         this.videocontrols = shadowRoot.firstChild;
         this.document = this.videocontrols.ownerDocument;
         this.window = this.document.defaultView;
         this.shadowRoot = shadowRoot;
 
         this.pictureInPictureToggleButton = this.shadowRoot.getElementById(
           "pictureInPictureToggleButton"
         );
 
+        this.pictureInPictureToggleExperiment = this.shadowRoot.getElementById(
+          "pictureInPictureToggleExperiment"
+        );
+
         if (this.document.fullscreenElement) {
           this.videocontrols.setAttribute("inDOMFullscreen", true);
         }
 
         // Default the Picture-in-Picture toggle button to being hidden. We might unhide it
         // later if we determine that this video is qualified to show it.
         this.pictureInPictureToggleButton.setAttribute("hidden", true);
 
@@ -3072,22 +3202,37 @@ this.NoControlsDesktopImplWidget = class
     parser.forceEnableDTD();
     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" href="chrome://global/skin/media/videocontrols.css" />
+        <link rel="stylesheet" href="chrome://global/skin/media/pictureinpicture-mode-1.css" mode="1" disabled="true" />
+        <link rel="stylesheet" href="chrome://global/skin/media/pictureinpicture-mode-2.css" mode="2" disabled="true" />
         <div id="controlsContainer" class="controlsContainer" role="none">
           <div class="controlsOverlay stackItem">
             <button id="pictureInPictureToggleButton" class="pictureInPictureToggleButton">
               <div id="pictureInPictureToggleIcon" class="pictureInPictureToggleIcon"></div>
               <span class="pictureInPictureToggleLabel">&pictureInPicture.label;</span>
             </button>
+            <button id="pictureInPictureToggleExperiment" class="pip-wrapper" position="left" hidden="true">
+              <div class="pip-small clickable"></div>
+              <div class="pip-expanded clickable">
+                <span class="pip-icon-label clickable">
+                  <span class="pip-icon"></span>
+                  <span class="pip-label">&pictureInPictureToggle.label;</span>
+                </span>
+                <div class="pip-explainer clickable">
+                  &pictureInPictureExplainer;
+                </div>
+              </div>
+              <div class="pip-icon"></div>
+            </button>
           </div>
         </div>
       </div>`,
       "application/xml"
     );
     this.shadowRoot.importNodeAndAppendChildAt(
       this.shadowRoot,
       parserDoc.documentElement,
--- a/toolkit/locales/en-US/chrome/global/videocontrols.dtd
+++ b/toolkit/locales/en-US/chrome/global/videocontrols.dtd
@@ -1,28 +1,41 @@
 <!-- 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/. -->
 
+<!ENTITY % brandDTD
+    SYSTEM "chrome://branding/locale/brand.dtd">
+  %brandDTD;
+
 <!ENTITY playButton.playLabel "Play">
 <!ENTITY playButton.pauseLabel "Pause">
 <!ENTITY muteButton.muteLabel "Mute">
 <!ENTITY muteButton.unmuteLabel "Unmute">
 <!ENTITY fullscreenButton.enterfullscreenlabel "Full Screen">
 <!ENTITY fullscreenButton.exitfullscreenlabel "Exit Full Screen">
 <!ENTITY castingButton.castingLabel "Cast to Screen">
 <!ENTITY closedCaption.off "Off">
 <!-- LOCALIZATION NOTE (volumeScrubber.label): This label is exposed to
      accessibility software to clarify what the slider is for. -->
 <!ENTITY volumeScrubber.label "Volume slider">
 
 <!-- LOCALIZATION NOTE (pictureInPicture.label): This string is used as part of
 the Picture-in-Picture video toggle button when the mouse is hovering it. -->
 <!ENTITY pictureInPicture.label "Picture-in-Picture">
 
+<!-- LOCALIZATION NOTE (pictureInPictureToggle.label): This string is used as the
+label for a variation of the Picture-in-Picture video toggle button when the mouse is
+hovering over the video. -->
+<!ENTITY pictureInPictureToggle.label "Watch in Picture-in-Picture">
+<!-- LOCALIZATION NOTE (pictureInPictureExplainer): This string is used as part of
+a variation of the Picture-in-Picture video toggle button. When using this variation,
+this string appears below the toggle when the mouse hovers the toggle. -->
+<!ENTITY pictureInPictureExplainer "Play videos in the foreground while you do other things in &brandShortName;">
+
 <!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.">
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -104,16 +104,18 @@ toolkit.jar:
   skin/classic/global/media/closedCaptionButton-cc-on.svg  (../../shared/media/closedCaptionButton-cc-on.svg)
   skin/classic/global/media/fullscreenEnterButton.svg      (../../shared/media/fullscreenEnterButton.svg)
   skin/classic/global/media/fullscreenExitButton.svg       (../../shared/media/fullscreenExitButton.svg)
   skin/classic/global/media/TopLevelImageDocument.css      (../../shared/media/TopLevelImageDocument.css)
   skin/classic/global/media/TopLevelVideoDocument.css      (../../shared/media/TopLevelVideoDocument.css)
   skin/classic/global/media/imagedoc-lightnoise.png        (../../shared/media/imagedoc-lightnoise.png)
   skin/classic/global/media/imagedoc-darknoise.png         (../../shared/media/imagedoc-darknoise.png)
 * skin/classic/global/media/videocontrols.css              (../../shared/media/videocontrols.css)
+  skin/classic/global/media/pictureinpicture-mode-1.css    (../../shared/media/pictureinpicture-mode-1.css)
+  skin/classic/global/media/pictureinpicture-mode-2.css    (../../shared/media/pictureinpicture-mode-2.css)
   skin/classic/global/media/pauseButton.svg                (../../shared/media/pauseButton.svg)
   skin/classic/global/media/playButton.svg                 (../../shared/media/playButton.svg)
   skin/classic/global/media/error.png                      (../../shared/media/error.png)
   skin/classic/global/media/throbber.png                   (../../shared/media/throbber.png)
   skin/classic/global/media/stalled.png                    (../../shared/media/stalled.png)
 #ifdef MOZ_PLACES
   skin/classic/mozapps/places/defaultFavicon.svg           (../../shared/places/defaultFavicon.svg)
 #endif
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/media/pictureinpicture-mode-1.css
@@ -0,0 +1,56 @@
+/* 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/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+
+.pip-expanded,
+.pip-small {
+  border: 1px solid rgba(255,255,255,0.1);
+  box-sizing: border-box;
+}
+
+.pip-expanded {
+  translate: -33%;
+  transition: opacity 250ms, scale 200ms, translate 190ms;
+  transition-timing-function: cubic-bezier(.07,.95,0,1);
+  justify-content: center;
+  pointer-events: none;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-label {
+  margin-right: var(--pip-icon-width-with-margins);
+  margin-left: var(--pip-toggle-margin);
+}
+
+.pip-wrapper[position="right"] > .pip-expanded {
+  translate: calc(-100% + 48px);
+  transform-origin: right;
+}
+
+.pip-wrapper.hovering:not([small-video]) > .pip-small + .pip-expanded,
+.pip-wrapper.hovering:not([small-video]) > .pip-expanded {
+  opacity: 1;
+  scale: 1;
+  pointer-events: auto;
+}
+
+.pip-wrapper[position="left"].hovering:not([small-video]) > .pip-expanded {
+  translate: 0;
+}
+
+.pip-wrapper[position="right"].hovering:not([small-video])  > .pip-small + .pip-expanded,
+.pip-wrapper[position="right"].hovering:not([small-video]) > .pip-expanded {
+  translate: calc(-100% + var(--pip-icon-width-with-margins));
+}
+
+.pip-wrapper.hovering:not([small-video]) > .pip-small {
+  opacity: 0;
+  transition: opacity 200ms;
+}
+
+.pip-wrapper:is([small-video]) > .pip-expanded,
+.pip-explainer,
+.pip-icon-label > .pip-icon {
+  display: none;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/media/pictureinpicture-mode-2.css
@@ -0,0 +1,126 @@
+/* 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/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+
+.pip-wrapper {
+  top: calc(70% - 40px);
+  --pip-highlight-width: 2px;
+}
+
+.pip-wrapper[position="left"] > .pip-expanded > .pip-icon-label > .pip-icon {
+  display: none;
+}
+
+.pip-expanded,
+.pip-small {
+  border: 1px solid rgba(255,255,255,0.1);
+  box-sizing: border-box;
+}
+
+.pip-expanded {
+  border: var(--pip-highlight-width) solid transparent;
+  border-bottom: 1px solid transparent;
+  transition: opacity 250ms, scale 200ms, translate 190ms;
+  justify-content: left;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded {
+  translate: calc(-100% + 48px);
+  transform-origin: right;
+  justify-content: right;
+}
+
+.pip-wrapper:is([small-video],[has-used]) > .pip-expanded,
+.pip-wrapper[position="right"]:not(:is([small-video],[has-used])) > .pip-icon {
+  display: none;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-icon {
+  position: relative;
+  top: 0;
+  left: 0;
+  display: inline-block;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label {
+  display: flex;
+  flex-direction: row;
+  align-content: center;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-icon,
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-label {
+  margin-top: auto;
+  margin-bottom: auto;
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-icon {
+  margin-left: var(--pip-toggle-margin);
+  margin-right: var(--pip-toggle-margin);
+}
+
+.pip-wrapper[position="right"] > .pip-expanded > .pip-icon-label > .pip-label {
+  margin-right: var(--pip-toggle-margin);
+}
+
+.pip-wrapper.hovering > .pip-expanded {
+  box-shadow: none;
+  border: var(--pip-highlight-width) solid rgba(0, 254, 255, 1);
+  /* Remove bottom border but keep text centred with padding. */
+  border-bottom: none;
+  padding-bottom: 1px;
+  pointer-events: none;
+}
+
+.pip-wrapper:not(:is([small-video],[has-used])) > .pip-expanded {
+  opacity: 1;
+  scale: 1;
+  pointer-events: auto;
+}
+
+.pip-wrapper:not(:is([small-video],[has-used])).hovering > .pip-expanded {
+  border-bottom-right-radius: 0px;
+  border-bottom-left-radius: 0px;
+}
+
+.pip-wrapper:not(:is([small-video],[has-used])) > .pip-small {
+  opacity: 0;
+  transition: opacity 200ms;
+}
+
+.pip-explainer {
+  opacity: 0;
+}
+
+.pip-explainer {
+  padding: 6px 16px 8px 8px;
+  translate: 0;
+  transition: opacity 250ms, translate 190ms;
+  transition-timing-function: cubic-bezier(.07,.95,0,1);
+  background: rgba(12,12,13,0.65);
+  border-bottom-right-radius: 8px;
+  border-bottom-left-radius: 8px;
+  border: 2px solid rgba(0, 254, 255, 1);
+  border-top: 0;
+  box-shadow: 0px 4px 4px rgba(12,12,13,0.25);
+  opacity: 0;
+  margin-left: -2px;
+  margin-right: -2px;
+  width: calc(100% - 24px);
+  word-break: break-word;
+  pointer-events: none;
+}
+
+.pip-wrapper.hovering > .pip-expanded > .pip-explainer {
+  pointer-events: auto;
+}
+
+.pip-wrapper.hovering > .pip-expanded > .pip-explainer {
+  opacity: 1;
+}
+
+.pip-wrapper.hovering > .pip-expanded > .pip-explainer {
+  translate: 0 calc(40px - var(--pip-highlight-width));
+}
--- a/toolkit/themes/shared/media/videocontrols.css
+++ b/toolkit/themes/shared/media/videocontrols.css
@@ -613,8 +613,118 @@
 %ifdef XP_WIN
 @media (-moz-windows-default-theme: 0) {
   .controlsSpacer,
   .clickToPlay {
     background-color: transparent;
   }
 }
 %endif
+
+.pip-wrapper {
+  position: absolute;
+  cursor: pointer;
+  -moz-appearance: none;
+  background: none;
+  border: none;
+  text-align: unset;
+  top: calc(75% - 40px);
+  opacity: 0;
+  transition: opacity 200ms;
+  --pip-icon-size: 24px;
+  --pip-toggle-margin: 8px;
+  --pip-icon-width-with-margins: calc(2 * var(--pip-toggle-margin) + var(--pip-icon-size));
+}
+
+.pip-wrapper[policy="hidden"] {
+  display: none;
+}
+
+.pip-label {
+  font-size: 16px;
+  line-height: 1.2;
+}
+
+.pip-expanded {
+  font-size: 14px;
+}
+
+.pip-wrapper[medium-video] > .pip-expanded > .pip-icon-label > .pip-label {
+  font-size: 13px;
+}
+
+.pip-wrapper[medium-video] > .pip-expanded {
+  font-size: 11px;
+}
+
+.controlsOverlay.hovering > .pip-wrapper {
+  opacity: 0.8;
+}
+
+.controlsOverlay[hidetoggle="true"].hovering > .pip-wrapper:not(.hovering) {
+  opacity: 0;
+}
+
+.controlsOverlay.hovering > .pip-wrapper.hovering {
+  opacity: 1;
+}
+
+.pip-wrapper[position="right"] {
+  right: 40px;
+}
+
+.pip-wrapper[position="left"] {
+  left: 12px;
+}
+
+.pip-expanded,
+.pip-small,
+.pip-icon,
+.pip-explainer {
+  position: absolute;
+  left: 0;
+  top: 0;
+}
+
+.pip-icon {
+  top: 8px;
+  left: 8px;
+  pointer-events: none;
+  background-image: url("chrome://global/skin/media/pictureinpicture.svg");
+  background-size: var(--pip-icon-size) var(--pip-icon-size);
+  -moz-context-properties: fill;
+  fill: #fff;
+  height: var(--pip-icon-size);
+  width: var(--pip-icon-size);
+  background-repeat: no-repeat;
+  background-position: center, center;
+}
+
+.pip-wrapper[position="left"] > .pip-expanded > .pip-icon-label > .pip-label {
+  margin-left: var(--pip-icon-width-with-margins);
+  margin-right: var(--pip-toggle-margin);
+}
+
+.pip-expanded,
+.pip-explainer {
+  user-select: none;
+}
+
+.pip-small {
+  background-color: rgba(12,12,13,0.65);
+  box-shadow: 0px 4px 4px rgba(12,12,13,0.25);
+  width: 40px;
+  height: 40px;
+  border-radius: 25px;
+}
+
+.pip-expanded {
+  display: flex;
+  height: 40px;
+  background-color: rgba(12,12,13,0.9);
+  box-shadow: 0px 4px 4px rgba(12,12,13,0.25);
+  width: max-content;
+  border-radius: 8px;
+  opacity: 0;
+  color: #fff;
+  align-items: center;
+  scale: 0.33 1;
+}