Backed out 10 changesets (bug 1542756, bug 1543128, bug 1543122) for multiple media failures /test_setSinkId.html. CLOSED TREE
authorCsoregi Natalia <ncsoregi@mozilla.com>
Sat, 13 Apr 2019 06:22:47 +0300
changeset 469388 5472f0a2d39db5d0854a0af0d3eedf930b685c85
parent 469387 34d4432920622d2d2bcdd865cc4ee609a2a8aebf
child 469389 9790d2e8f40b840c55d15b918162b040a2a92e0e
push id35865
push userapavel@mozilla.com
push dateSat, 13 Apr 2019 21:44:49 +0000
treeherdermozilla-central@2c3837b46068 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1542756, 1543128, 1543122
milestone68.0a1
backs outce3a15e1b7373a147f13706856511f2e4bc07261
cea8c1af70ad885ef143ad9e0cf7366ac4c1459c
aeb23f8f45fbcae94ed112ab5252a2b92b00f679
a2e73d143aba34a6a61f30127377fc9482de66a4
1692fc6491a02f093482f6468a4056e1e4d65a9a
9fbce4274cfd82e5b04da99505339ea6c43508ab
20092bcebe6aed3279e9f05290c7ecf7442c3586
1645d577016c1502409973290078f94a16932037
3fce0b7586c19e78095d1c5050cd51fb6b9d82b4
aab68db4131baab15a3c4850d73b1fa9ed4a51df
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
Backed out 10 changesets (bug 1542756, bug 1543128, bug 1543122) for multiple media failures /test_setSinkId.html. CLOSED TREE Backed out changeset ce3a15e1b737 (bug 1543128) Backed out changeset cea8c1af70ad (bug 1543128) Backed out changeset aeb23f8f45fb (bug 1543128) Backed out changeset a2e73d143aba (bug 1543128) Backed out changeset 1692fc6491a0 (bug 1543128) Backed out changeset 9fbce4274cfd (bug 1542756) Backed out changeset 20092bcebe6a (bug 1543122) Backed out changeset 1645d577016c (bug 1543122) Backed out changeset 3fce0b7586c1 (bug 1543122) Backed out changeset aab68db4131b (bug 1543122)
browser/base/content/test/static/browser_parsable_css.js
browser/components/downloads/content/allDownloadsView.js
dom/base/DocumentOrShadowRoot.cpp
dom/base/DocumentOrShadowRoot.h
dom/base/nsDOMWindowUtils.cpp
dom/html/HTMLMediaElement.cpp
dom/html/HTMLVideoElement.cpp
dom/html/HTMLVideoElement.h
dom/interfaces/base/nsIDOMWindowUtils.idl
dom/tests/mochitest/chrome/489127.html
dom/webidl/HTMLVideoElement.webidl
toolkit/actors/PictureInPictureChild.jsm
toolkit/actors/UAWidgetsChild.jsm
toolkit/content/widgets/videocontrols.js
toolkit/locales/en-US/toolkit/global/videocontrols.ftl
toolkit/themes/shared/jar.inc.mn
toolkit/themes/shared/media/videocontrols.css
toolkit/themes/shared/pictureinpicture/toggle.css
--- a/browser/base/content/test/static/browser_parsable_css.js
+++ b/browser/base/content/test/static/browser_parsable_css.js
@@ -60,16 +60,19 @@ let whitelist = [
   {sourceName: /webide\/skin\/logs\.css$/i,
    intermittent: true,
    errorMessage: /Property contained reference to invalid variable.*color/i,
    isFromDevTools: true},
   {sourceName: /webide\/skin\/logs\.css$/i,
    intermittent: true,
    errorMessage: /Property contained reference to invalid variable.*background/i,
    isFromDevTools: true},
+  {sourceName: /pictureinpicture\/toggle.css$/i,
+   errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
+   isFromDevTools: false},
 ];
 
 if (!Services.prefs.getBoolPref("layout.css.xul-box-display-values.content.enabled")) {
   // These are UA sheets which use non-content-exposed `display` values.
   whitelist.push({
     sourceName: /(skin\/shared\/Heartbeat|((?:res|gre-resources)\/(ua|html)))\.css$/i,
     errorMessage: /Error in parsing value for .*\bdisplay\b/i,
     isFromDevTools: false,
--- a/browser/components/downloads/content/allDownloadsView.js
+++ b/browser/components/downloads/content/allDownloadsView.js
@@ -243,17 +243,17 @@ DownloadsPlacesView.prototype = {
       if (!this._richlistbox.firstChild) {
         return;
       }
 
       let rlbRect = this._richlistbox.getBoundingClientRect();
       let winUtils = window.windowUtils;
       let nodes = winUtils.nodesFromRect(rlbRect.left, rlbRect.top,
                                          0, rlbRect.width, rlbRect.height, 0,
-                                         true, false, false);
+                                         true, false);
       // nodesFromRect returns nodes in z-index order, and for the same z-index
       // sorts them in inverted DOM order, thus starting from the one that would
       // be on top.
       let firstVisibleNode, lastVisibleNode;
       for (let node of nodes) {
         if (node.localName === "richlistitem" && node._shell) {
           node._shell.ensureActive();
           // The first visible node is the last match.
--- a/dom/base/DocumentOrShadowRoot.cpp
+++ b/dom/base/DocumentOrShadowRoot.cpp
@@ -357,17 +357,16 @@ Element* DocumentOrShadowRoot::ElementFr
   return elements.SafeElementAt(0);
 }
 
 void DocumentOrShadowRoot::NodesFromRect(float aX, float aY, float aTopSize,
                                          float aRightSize, float aBottomSize,
                                          float aLeftSize,
                                          bool aIgnoreRootScrollFrame,
                                          bool aFlushLayout,
-                                         bool aOnlyVisible,
                                          nsTArray<RefPtr<nsINode>>& aReturn) {
   // Following the same behavior of elementFromPoint,
   // we don't return anything if either coord is negative
   if (!aIgnoreRootScrollFrame && (aX < 0 || aY < 0)) {
     return;
   }
 
   nscoord x = nsPresContext::CSSPixelsToAppUnits(aX - aLeftSize);
@@ -376,19 +375,16 @@ void DocumentOrShadowRoot::NodesFromRect
   nscoord h = nsPresContext::CSSPixelsToAppUnits(aTopSize + aBottomSize) + 1;
 
   nsRect rect(x, y, w, h);
 
   EnumSet<FrameForPointOption> options;
   if (aIgnoreRootScrollFrame) {
     options += FrameForPointOption::IgnoreRootScrollFrame;
   }
-  if (aOnlyVisible) {
-    options += FrameForPointOption::OnlyVisible;
-  }
 
   auto flush = aFlushLayout ? FlushLayout::Yes : FlushLayout::No;
   QueryNodesFromRect(*this, rect, options, flush, Multiple::Yes, aReturn);
 }
 
 Element* DocumentOrShadowRoot::AddIDTargetObserver(nsAtom* aID,
                                                    IDTargetObserver aObserver,
                                                    void* aData,
--- a/dom/base/DocumentOrShadowRoot.h
+++ b/dom/base/DocumentOrShadowRoot.h
@@ -115,17 +115,17 @@ class DocumentOrShadowRoot {
    */
   Element* ElementFromPointHelper(float aX, float aY,
                                   bool aIgnoreRootScrollFrame,
                                   bool aFlushLayout);
 
   void NodesFromRect(float aX, float aY, float aTopSize, float aRightSize,
                      float aBottomSize, float aLeftSize,
                      bool aIgnoreRootScrollFrame, bool aFlushLayout,
-                     bool aOnlyVisible, nsTArray<RefPtr<nsINode>>&);
+                     nsTArray<RefPtr<nsINode>>&);
 
   /**
    * This gets fired when the element that an id refers to changes.
    * This fires at difficult times. It is generally not safe to do anything
    * which could modify the DOM in any way. Use
    * nsContentUtils::AddScriptRunner.
    * @return true to keep the callback in the callback set, false
    * to remove it.
--- a/dom/base/nsDOMWindowUtils.cpp
+++ b/dom/base/nsDOMWindowUtils.cpp
@@ -1150,29 +1150,27 @@ nsDOMWindowUtils::ElementFromPoint(float
   el.forget(aReturn);
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsDOMWindowUtils::NodesFromRect(float aX, float aY, float aTopSize,
                                 float aRightSize, float aBottomSize,
                                 float aLeftSize, bool aIgnoreRootScrollFrame,
-                                bool aFlushLayout, bool aOnlyVisible,
-                                nsINodeList** aReturn) {
+                                bool aFlushLayout, nsINodeList** aReturn) {
   nsCOMPtr<Document> doc = GetDocument();
   NS_ENSURE_STATE(doc);
 
   nsSimpleContentList* list = new nsSimpleContentList(doc);
   NS_ADDREF(list);
   *aReturn = list;
 
   AutoTArray<RefPtr<nsINode>, 8> nodes;
   doc->NodesFromRect(aX, aY, aTopSize, aRightSize, aBottomSize, aLeftSize,
-                     aIgnoreRootScrollFrame, aFlushLayout, aOnlyVisible,
-                     nodes);
+                     aIgnoreRootScrollFrame, aFlushLayout, nodes);
   list->SetCapacity(nodes.Length());
   for (auto& node : nodes) {
     list->AppendElement(node->AsContent());
   }
   return NS_OK;
 }
 
 NS_IMETHODIMP
--- a/dom/html/HTMLMediaElement.cpp
+++ b/dom/html/HTMLMediaElement.cpp
@@ -4092,17 +4092,29 @@ void HTMLMediaElement::AfterMaybeChangeA
 nsresult HTMLMediaElement::BindToTree(Document* aDocument, nsIContent* aParent,
                                       nsIContent* aBindingParent) {
   nsresult rv =
       nsGenericHTMLElement::BindToTree(aDocument, aParent, aBindingParent);
 
   if (IsInComposedDoc()) {
     // Construct Shadow Root so web content can be hidden in the DOM.
     AttachAndSetUAShadowRoot();
+#ifdef ANDROID
     NotifyUAWidgetSetupOrChange();
+#else
+    // We don't want to call into JS if the website never asks for native
+    // video controls.
+    // If controls attribute is set later, controls is constructed lazily
+    // with the UAWidgetAttributeChanged event.
+    // This only applies to Desktop because on Fennec we would need to show
+    // an UI if the video is blocked.
+    if (Controls()) {
+      NotifyUAWidgetSetupOrChange();
+    }
+#endif
   }
 
   mUnboundFromTree = false;
 
   if (aDocument) {
     // The preload action depends on the value of the autoplay attribute.
     // It's value may have changed, so update it.
     UpdatePreloadAction();
--- a/dom/html/HTMLVideoElement.cpp
+++ b/dom/html/HTMLVideoElement.cpp
@@ -543,20 +543,10 @@ void HTMLVideoElement::EndCloningVisuall
   Unused << mVisualCloneTarget->SetVisualCloneSource(nullptr);
   Unused << SetVisualCloneTarget(nullptr);
 
   if (IsInComposedDoc() && !sCloneElementVisuallyTesting) {
     NotifyUAWidgetSetupOrChange();
   }
 }
 
-void HTMLVideoElement::TogglePictureInPicture(ErrorResult& error) {
-  // The MozTogglePictureInPicture event is listen for via the
-  // PictureInPictureChild actor, which is responsible for opening the new
-  // window and starting the visual clone.
-  nsresult rv = DispatchEvent(NS_LITERAL_STRING("MozTogglePictureInPicture"));
-  if (NS_FAILED(rv)) {
-    error.Throw(rv);
-  }
-}
-
 }  // namespace dom
 }  // namespace mozilla
--- a/dom/html/HTMLVideoElement.h
+++ b/dom/html/HTMLVideoElement.h
@@ -140,18 +140,16 @@ class HTMLVideoElement final : public HT
   void SetMozIsOrientationLocked(bool aLock) { mIsOrientationLocked = aLock; }
 
   void CloneElementVisually(HTMLVideoElement& aTarget, ErrorResult& rv);
 
   void StopCloningElementVisually();
 
   bool IsCloningElementVisually() const { return !!mVisualCloneTarget; }
 
-  void TogglePictureInPicture(ErrorResult& rv);
-
  protected:
   virtual ~HTMLVideoElement();
 
   virtual JSObject* WrapNode(JSContext* aCx,
                              JS::Handle<JSObject*> aGivenProto) override;
 
   /**
    * We create video wakelock when the video is playing and release it when
--- a/dom/interfaces/base/nsIDOMWindowUtils.idl
+++ b/dom/interfaces/base/nsIDOMWindowUtils.idl
@@ -740,28 +740,25 @@ interface nsIDOMWindowUtils : nsISupport
    * @param aTopSize How much to expand up the rectangle
    * @param aRightSize How much to expand right the rectangle
    * @param aBottomSize How much to expand down the rectangle
    * @param aLeftSize How much to expand left the rectangle
    * @param aIgnoreRootScrollFrame whether or not to ignore the root scroll
    *        frame when retrieving the element. If false, this method returns
    *        null for coordinates outside of the viewport.
    * @param aFlushLayout flushes layout if true. Otherwise, no flush occurs.
-   * @param aOnlyVisible Set to true if you only want nodes that pass a visibility
-   *        hit test.
    */
   NodeList nodesFromRect(in float aX,
                          in float aY,
                          in float aTopSize,
                          in float aRightSize,
                          in float aBottomSize,
                          in float aLeftSize,
                          in boolean aIgnoreRootScrollFrame,
-                         in boolean aFlushLayout,
-                         in boolean aOnlyVisible);
+                         in boolean aFlushLayout);
 
 
   /**
    * Get a list of nodes that have meaningful textual content to
    * be translated. The implementation of this algorithm is in flux
    * as we experiment and refine which approach works best.
    *
    * This method requires chrome privileges.
--- a/dom/tests/mochitest/chrome/489127.html
+++ b/dom/tests/mochitest/chrome/489127.html
@@ -8,18 +8,30 @@
 
   var SimpleTest = window.opener.SimpleTest;
   function ok() { window.opener.ok.apply(window.opener, arguments); }
   function done() { window.opener.done.apply(window.opener, arguments); }
   let e = {};
 
   let dwu = window.windowUtils;
 
+  /*
+    NodeList nodesFromRect(in float aX,
+                           in float aY,
+                           in float aTopSize, 
+                           in float aRightSize,
+                           in float aBottomSize,
+                           in float aLeftSize,
+                           in boolean aIgnoreRootScrollFrame,
+                           in boolean aFlushLayout);
+
+  */
+
   function check(x, y, top, right, bottom, left, list) {
-    let nodes = dwu.nodesFromRect(x, y, top, right, bottom, left, true, false, false);
+    let nodes = dwu.nodesFromRect(x, y, top, right, bottom, left, true, false);
     
     list.push(e.body);
     list.push(e.html);
 
     if (nodes.length != list.length) {
       ok(false, "Different number of nodes for rect" +
                 "[" + x + "," + y + "], " + 
                 "[" + top + "," + right + "," + bottom + "," + left + "]");
--- a/dom/webidl/HTMLVideoElement.webidl
+++ b/dom/webidl/HTMLVideoElement.webidl
@@ -64,21 +64,15 @@ partial interface HTMLVideoElement {
   // wasn't cloning in the first place.
   [Func="IsChromeOrXBLOrUAWidget"]
     void stopCloningElementVisually();
 
   // Returns true if the <video> is being cloned visually to another
   // <video> element (see cloneElementVisually).
   [Func="IsChromeOrXBLOrUAWidget"]
     readonly attribute boolean isCloningElementVisually;
-
-  // Fires the privileged MozTogglePictureInPicture event to enter
-  // Picture-in-Picture. Call this when triggering Picture-in-Picture
-  // from the video controls UAWidget.
-  [Throws, Func="IsChromeOrXBLOrUAWidget"]
-    void togglePictureInPicture();
 };
 
 // https://dvcs.w3.org/hg/html-media/raw-file/default/media-source/media-source.html#idl-def-HTMLVideoElement
 partial interface HTMLVideoElement {
   [Func="mozilla::dom::MediaSource::Enabled", NewObject]
   VideoPlaybackQuality getVideoPlaybackQuality();
 };
--- a/toolkit/actors/PictureInPictureChild.jsm
+++ b/toolkit/actors/PictureInPictureChild.jsm
@@ -2,51 +2,74 @@
 /* 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/. */
 "use strict";
 
 var EXPORTED_SYMBOLS = ["PictureInPictureChild", "PictureInPictureToggleChild"];
 
 const {ActorChild} = ChromeUtils.import("resource://gre/modules/ActorChild.jsm");
-const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 ChromeUtils.defineModuleGetter(this, "DeferredTask",
   "resource://gre/modules/DeferredTask.jsm");
+ChromeUtils.defineModuleGetter(this, "DOMLocalization",
+  "resource://gre/modules/DOMLocalization.jsm");
 ChromeUtils.defineModuleGetter(this, "Services",
   "resource://gre/modules/Services.jsm");
 
-XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
-
+const TOGGLE_STYLESHEET = "chrome://global/skin/pictureinpicture/toggle.css";
+const TOGGLE_ID = "picture-in-picture-toggle";
+const FLYOUT_TOGGLE_ID = "picture-in-picture-flyout-toggle";
+const FLYOUT_TOGGLE_CONTAINER = "picture-in-picture-flyout-container";
 const TOGGLE_ENABLED_PREF =
   "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const FLYOUT_ENABLED_PREF =
+  "media.videocontrols.picture-in-picture.video-toggle.flyout-enabled";
+const FLYOUT_WAIT_MS_PREF =
+  "media.videocontrols.picture-in-picture.video-toggle.flyout-wait-ms";
+const FLYOUT_ANIMATION_RUNTIME_MS = 400;
 const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
 
 // 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.
 var gWeakPlayerContent = null;
+// A process-global Promise that's set the first time the string for the
+// flyout toggle label is requested from Fluent.
+var gFlyoutLabelPromise = null;
+// A process-global for the width of the toggle icon. We stash this here after
+// computing it the first time to avoid repeatedly flushing styles.
+var gToggleWidth = 0;
 
 /**
  * The PictureInPictureToggleChild is responsible for displaying the overlaid
  * Picture-in-Picture toggle over top of <video> elements that the mouse is
  * hovering.
+ *
+ * It's also responsible for showing the "flyout" version of the toggle, which
+ * currently displays on the first visible video per page.
  */
 class PictureInPictureToggleChild extends ActorChild {
   constructor(dispatcher) {
     super(dispatcher);
     // 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.flyoutEnabled = Services.prefs.getBoolPref(FLYOUT_ENABLED_PREF);
+    this.flyoutWaitMs = Services.prefs.getIntPref(FLYOUT_WAIT_MS_PREF);
+
+    this.l10n = new DOMLocalization([
+      "toolkit/global/videocontrols.ftl",
+    ]);
   }
 
   /**
    * Returns the state for the current document referred to via
    * this.content.document. If no such state exists, creates it, stores it
    * and returns it.
    */
   get docState() {
@@ -56,42 +79,64 @@ class PictureInPictureToggleChild extend
         // A reference to the IntersectionObserver that's monitoring for videos
         // to become visible.
         intersectionObserver: null,
         // A WeakSet of videos that are supposedly visible, according to the
         // IntersectionObserver.
         weakVisibleVideos: new WeakSet(),
         // The number of videos that are supposedly visible, according to the
         // IntersectionObserver
-        visibleVideosCount: 0,
+        visibleVideos: 0,
         // The DeferredTask that we'll arm every time a mousemove event occurs
         // on a page where we have one or more visible videos.
         mousemoveDeferredTask: null,
         // A weak reference to the last video we displayed the toggle over.
         weakOverVideo: null,
+        // A reference to the AnonymousContent returned after inserting the
+        // small toggle.
+        pipToggle: null,
+        // A reference to the AnonymousContent returned after inserting the
+        // flyout toggle.
+        flyoutToggle: null,
       };
       this.weakDocStates.set(this.content.document, state);
     }
 
     return state;
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "canplay": {
         if (this.toggleEnabled &&
             event.target instanceof this.content.HTMLVideoElement &&
-            !event.target.controls &&
             event.target.ownerDocument == this.content.document) {
           this.registerVideo(event.target);
         }
         break;
       }
-      case "mousedown": {
-        this.onMouseDown(event);
+      case "click": {
+        let state = this.docState;
+        let clickedFlyout = state.flyoutToggle &&
+          state.flyoutToggle.getTargetIdForEvent(event) == FLYOUT_TOGGLE_ID;
+        let clickedToggle = state.pipToggle &&
+          state.pipToggle.getTargetIdForEvent(event) == TOGGLE_ID;
+
+        if (clickedFlyout || clickedToggle) {
+          let video = state.weakOverVideo && state.weakOverVideo.get();
+          if (video) {
+            let pipEvent =
+              new this.content.CustomEvent("MozTogglePictureInPicture", {
+                bubbles: true,
+              });
+            video.dispatchEvent(pipEvent);
+            this.hideFlyout();
+            this.onMouseLeaveVideo(video);
+          }
+        }
         break;
       }
       case "mousemove": {
         this.onMouseMove(event);
         break;
       }
     }
   }
@@ -102,17 +147,17 @@ class PictureInPictureToggleChild extend
    *
    * @param {Element} video The <video> element to register.
    */
   registerVideo(video) {
     let state = this.docState;
     if (!state.intersectionObserver) {
       let fn = this.onIntersection.bind(this);
       state.intersectionObserver = new this.content.IntersectionObserver(fn, {
-        threshold: [0.0, 0.5],
+        threshold: [0.0, 1.0],
       });
     }
 
     state.intersectionObserver.observe(video);
   }
 
   /**
    * Called by the IntersectionObserver callback once a video becomes visible.
@@ -121,52 +166,66 @@ class PictureInPictureToggleChild extend
    * that means that the entirety of the video must be in the viewport.
    *
    * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
    * the IntersectionObserver callback.
    * @return bool Whether or not we should start tracking mousemove events for
    * this registered video.
    */
   worthTracking(intersectionEntry) {
-    return intersectionEntry.isIntersecting;
+    let video = intersectionEntry.target;
+    let rect = video.ownerGlobal.windowUtils.getBoundsWithoutFlushing(video);
+    let intRect = intersectionEntry.intersectionRect;
+
+    return intersectionEntry.isIntersecting &&
+           rect.width == intRect.width &&
+           rect.height == intRect.height;
   }
 
   /**
    * Called by the IntersectionObserver once a video crosses one of the
    * thresholds dictated by the IntersectionObserver configuration.
    *
    * @param {Array<IntersectionEntry>} A collection of one or more
    * IntersectionEntry's for <video> elements that might have entered or exited
    * the viewport.
    */
   onIntersection(entries) {
     // The IntersectionObserver will also fire when a previously intersecting
     // element is removed from the DOM. We know, however, that the node is
     // still alive and referrable from the WeakSet because the
     // IntersectionObserverEntry holds a strong reference to the video.
     let state = this.docState;
-    let oldVisibleVideosCount = state.visibleVideosCount;
+    let oldVisibleVideos = state.visibleVideos;
     for (let entry of entries) {
       let video = entry.target;
       if (this.worthTracking(entry)) {
         if (!state.weakVisibleVideos.has(video)) {
           state.weakVisibleVideos.add(video);
-          state.visibleVideosCount++;
+          state.visibleVideos++;
+
+          // The very first video that we notice is worth tracking, we'll show
+          // the flyout toggle on.
+          if (this.flyoutEnabled) {
+            this.content.requestIdleCallback(() => {
+              this.maybeShowFlyout(video);
+            });
+          }
         }
       } else if (state.weakVisibleVideos.has(video)) {
         state.weakVisibleVideos.delete(video);
-        state.visibleVideosCount--;
+        state.visibleVideos--;
       }
     }
 
-    if (!oldVisibleVideosCount && state.visibleVideosCount) {
+    if (!oldVisibleVideos && state.visibleVideos) {
       this.content.requestIdleCallback(() => {
         this.beginTrackingMouseOverVideos();
       });
-    } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
+    } else if (oldVisibleVideos && !state.visibleVideos) {
       this.content.requestIdleCallback(() => {
         this.stopTrackingMouseOverVideos();
       });
     }
   }
 
   /**
    * One of the challenges of displaying this toggle is that many sites put
@@ -184,93 +243,40 @@ class PictureInPictureToggleChild extend
   beginTrackingMouseOverVideos() {
     let state = this.docState;
     if (!state.mousemoveDeferredTask) {
       state.mousemoveDeferredTask = new DeferredTask(() => {
         this.checkLastMouseMove();
       }, MOUSEMOVE_PROCESSING_DELAY_MS);
     }
     this.content.document.addEventListener("mousemove", this,
-                                           { mozSystemGroup: true, capture: true });
-    // We want to try to cancel the mouse events from continuing
-    // on into content if the user has clicked on the toggle, so
-    // we don't use the mozSystemGroup here.
-    this.content.document.addEventListener("mousedown", this,
-                                           { capture: true });
+                                           { mozSystemGroup: true });
+    this.content.document.addEventListener("click", this,
+                                           { mozSystemGroup: true });
   }
 
   /**
    * If we no longer have any interesting videos in the viewport, we deregister
    * the mousemove and click listeners, and also remove any toggles that might
    * be on the page still.
    */
   stopTrackingMouseOverVideos() {
     let state = this.docState;
     state.mousemoveDeferredTask.disarm();
     this.content.document.removeEventListener("mousemove", this,
-                                              { mozSystemGroup: true, capture: true });
-    this.content.document.removeEventListener("mousedown", this,
-                                              { capture: true });
+                                              { mozSystemGroup: true });
+    this.content.document.removeEventListener("click", this,
+                                              { mozSystemGroup: true });
     let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
     if (oldOverVideo) {
       this.onMouseLeaveVideo(oldOverVideo);
     }
   }
 
   /**
-   * If we're tracking <video> elements, this mousedown event handler is run anytime
-   * a mousedown occurs on the document. This function is responsible for checking
-   * if the user clicked on the Picture-in-Picture toggle. It does this by first
-   * checking if the video is visible beneath the point that was clicked. Then
-   * it tests whether or not the mousedown occurred within the rectangle of the
-   * toggle. If so, the event's default behaviour and propagation are stopped,
-   * and Picture-in-Picture is triggered.
-   *
-   * @param {Event} event The mousemove event.
-   */
-  onMouseDown(event) {
-    let state = this.docState;
-    let video = state.weakOverVideo && state.weakOverVideo.get();
-    if (!video) {
-      return;
-    }
-
-    let shadowRoot = video.openOrClosedShadowRoot;
-    if (!shadowRoot) {
-      return;
-    }
-
-    let { clientX, clientY } = event;
-    let winUtils = this.content.windowUtils;
-    // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
-    // since document.elementsFromPoint always flushes layout. The 1's in that
-    // function call are for the size of the rect that we want, which is 1x1.
-    //
-    // We pass the aOnlyVisible boolean argument to check that the video isn't
-    // occluded by anything visible at the point of mousedown. If it is, we'll
-    // ignore the mousedown.
-    let elements = winUtils.nodesFromRect(clientX, clientY, 1, 1, 1, 1, true,
-                                          false, true /* aOnlyVisible */);
-    if (!Array.from(elements).includes(video)) {
-      return;
-    }
-
-    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
-    if (this.isMouseOverToggle(toggle, event)) {
-      event.preventDefault();
-      event.stopPropagation();
-      let pipEvent =
-        new this.content.CustomEvent("MozTogglePictureInPicture", {
-          bubbles: true,
-        });
-      video.dispatchEvent(pipEvent);
-    }
-  }
-
-  /**
    * Called for each mousemove event when we're tracking those events to
    * determine if the cursor is hovering over a <video>.
    *
    * @param {Event} event The mousemove event.
    */
   onMouseMove(event) {
     let state = this.docState;
     state.lastMouseMoveEvent = event;
@@ -287,133 +293,254 @@ class PictureInPictureToggleChild extend
     let state = this.docState;
     let event = state.lastMouseMoveEvent;
     let { clientX, clientY } = event;
     let winUtils = this.content.windowUtils;
     // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
     // since document.elementsFromPoint always flushes layout. The 1's in that
     // function call are for the size of the rect that we want, which is 1x1.
     let elements = winUtils.nodesFromRect(clientX, clientY, 1, 1, 1, 1, true,
-                                          false, false);
+                                          false);
 
     for (let element of elements) {
       if (state.weakVisibleVideos.has(element) &&
           !element.isCloningElementVisually) {
-        this.onMouseOverVideo(element, event);
+        this.onMouseOverVideo(element);
         return;
       }
     }
 
     let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
     if (oldOverVideo) {
       this.onMouseLeaveVideo(oldOverVideo);
     }
   }
 
   /**
    * Called once it has been determined that the mouse is overtop of a video
    * that is in the viewport.
    *
    * @param {Element} video The video the mouse is over.
    */
-  onMouseOverVideo(video, event) {
+  onMouseOverVideo(video) {
     let state = this.docState;
     let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
-    let shadowRoot = video.openOrClosedShadowRoot;
-
-    // It seems from automated testing that if it's still very early on in the
-    // lifecycle of a <video> element, it might not yet have a shadowRoot,
-    // in which case, we can bail out here early.
-    if (!shadowRoot) {
-      if (oldOverVideo) {
-        // We also clear the hover state on the old video we were hovering,
-        // if there was one.
-        this.onMouseLeaveVideo(oldOverVideo);
-      }
-
+    if (oldOverVideo && oldOverVideo == video) {
       return;
     }
 
-    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
-
-    if (oldOverVideo) {
-      if (oldOverVideo == video) {
-        // If we're still hovering the old video, we might have entered or
-        // exited the toggle region.
-        this.checkHoverToggle(toggle, event);
-        return;
-      }
-
-      // We had an old video that we were hovering, and we're not hovering
-      // it anymore. Let's leave it.
-      this.onMouseLeaveVideo(oldOverVideo);
-    }
-
     state.weakOverVideo = Cu.getWeakReference(video);
-    let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
-    InspectorUtils.addPseudoClassLock(controlsOverlay, ":hover");
-
-    // Now that we're hovering the video, we'll check to see if we're
-    // hovering the toggle too.
-    this.checkHoverToggle(toggle, event);
-  }
-
-  /**
-   * Checks if a mouse event is happening over a toggle element. If it is,
-   * sets the :hover pseudoclass on it. Otherwise, it clears the :hover
-   * pseudoclass.
-   *
-   * @param {Element} toggle The Picture-in-Picture toggle to check.
-   * @param {MouseEvent} event A MouseEvent to test.
-   */
-  checkHoverToggle(toggle, event) {
-    if (this.isMouseOverToggle(toggle, event)) {
-      InspectorUtils.addPseudoClassLock(toggle, ":hover");
-    } else {
-      InspectorUtils.removePseudoClassLock(toggle, ":hover");
-    }
+    this.moveToggleToVideo(video);
   }
 
   /**
    * Called once it has been determined that the mouse is no longer overlapping
    * a video that we'd previously called onMouseOverVideo with.
    *
    * @param {Element} video The video that the mouse left.
    */
   onMouseLeaveVideo(video) {
     let state = this.docState;
-    let shadowRoot = video.openOrClosedShadowRoot;
+    state.weakOverVideo = null;
+    state.pipToggle.setAttributeForElement(TOGGLE_ID, "hidden", "true");
+  }
 
-    if (shadowRoot) {
-      let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
-      let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
-      InspectorUtils.removePseudoClassLock(controlsOverlay, ":hover");
-      InspectorUtils.removePseudoClassLock(toggle, ":hover");
+  /**
+   * The toggle is injected as AnonymousContent that is positioned absolutely.
+   * This method takes the <video> that we want to display the toggle on and
+   * calculates where exactly we need to position the AnonymousContent in
+   * absolute coordinates.
+   *
+   * @param {Element} video The video to display the toggle on.
+   * @param {AnonymousContent} anonymousContent The anonymousContent associated
+   * with the toggle about to be shown.
+   * @param {String} toggleID The ID of the toggle element with the CSS
+   * variables defining the toggle width and padding.
+   *
+   * @return {Object} with the following properties:
+   *   {Number} top The top / y coordinate.
+   *   {Number} left The left / x coordinate.
+   *   {Number} width The width of the toggle icon, including padding.
+   */
+  calculateTogglePosition(video, anonymousContent, toggleID) {
+    let winUtils = this.content.windowUtils;
+
+    let scrollX = {}, scrollY = {};
+    winUtils.getScrollXY(false, scrollX, scrollY);
+
+    let rect = winUtils.getBoundsWithoutFlushing(video);
+
+    // For now, using AnonymousContent.getComputedStylePropertyValue causes
+    // a style flush, so we'll cache the value in this content process the
+    // first time we read it. See bug 1541207.
+    if (!gToggleWidth) {
+      let widthStr = anonymousContent.getComputedStylePropertyValue(toggleID,
+        "--pip-toggle-icon-width-height");
+      let paddingStr = anonymousContent.getComputedStylePropertyValue(toggleID,
+        "--pip-toggle-padding");
+      let iconWidth = parseInt(widthStr, 0);
+      let iconPadding = parseInt(paddingStr, 0);
+      gToggleWidth = iconWidth + (2 * iconPadding);
     }
 
-    state.weakOverVideo = null;
+    let originY = rect.top + scrollY.value;
+    let originX = rect.left + scrollX.value;
+
+    let top = originY + (rect.height / 2 - Math.round(gToggleWidth / 2));
+    let left = originX + (rect.width - gToggleWidth);
+
+    return { top, left, width: gToggleWidth };
+  }
+
+  /**
+   * Puts the small "Picture-in-Picture" toggle onto the passed in video.
+   *
+   * @param {Element} video The video to display the toggle on.
+   */
+  moveToggleToVideo(video) {
+    let state = this.docState;
+    let winUtils = this.content.windowUtils;
+
+    if (!state.pipToggle) {
+      try {
+        winUtils.loadSheetUsingURIString(TOGGLE_STYLESHEET,
+                                         winUtils.AGENT_SHEET);
+      } catch (e) {
+        // This method can fail with NS_ERROR_INVALID_ARG if the sheet is
+        // already loaded - for example, from the flyout toggle.
+        if (e.result != Cr.NS_ERROR_INVALID_ARG) {
+          throw e;
+        }
+      }
+      let toggle = this.content.document.createElement("button");
+      toggle.classList.add("picture-in-picture-toggle-button");
+      toggle.id = TOGGLE_ID;
+      let icon = this.content.document.createElement("div");
+      icon.classList.add("icon");
+      toggle.appendChild(icon);
+
+      state.pipToggle = this.content.document.insertAnonymousContent(toggle);
+    }
+
+    let { top, left } = this.calculateTogglePosition(video, state.pipToggle,
+                                                     TOGGLE_ID);
+
+    let styles = `
+      top: ${top}px;
+      left: ${left}px;
+    `;
+
+    let toggle = state.pipToggle;
+    toggle.setAttributeForElement(TOGGLE_ID, "style", styles);
+    // The toggle might have been hidden after a previous appearance.
+    toggle.removeAttributeForElement(TOGGLE_ID, "hidden");
   }
 
   /**
-   * Given a reference to a Picture-in-Picture toggle element, determines
-   * if a MouseEvent event is occurring within its bounds.
+   * Lazy getter that returns a Promise that resolves to the flyout toggle
+   * label string. Sets a process-global variable to the Promise so that
+   * subsequent calls within the same process don't cause us to go through
+   * the Fluent look-up path again.
+   */
+  get flyoutLabel() {
+    if (gFlyoutLabelPromise) {
+      return gFlyoutLabelPromise;
+    }
+
+    gFlyoutLabelPromise =
+      this.l10n.formatValue("picture-in-picture-flyout-toggle");
+    return gFlyoutLabelPromise;
+  }
+
+  /**
+   * If configured to, will display the "Picture-in-Picture" flyout toggle on
+   * the passed-in video. This is an asynchronous function that handles the
+   * entire lifecycle of the flyout animation. If a flyout toggle has already
+   * been seen on this page, this function does nothing.
    *
-   * @param {Element} toggle The Picture-in-Picture toggle.
-   * @param {MouseEvent} event A MouseEvent to test.
+   * @param {Element} video The video to display the flyout on.
    *
-   * @return {Boolean}
+   * @return {Promise}
+   * @resolves {undefined} Once the flyout toggle animation has completed.
    */
-  isMouseOverToggle(toggle, event) {
-    let toggleRect =
-      toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(toggle);
-    let { clientX, clientY } = event;
-    return clientX >= toggleRect.left &&
-           clientX <= toggleRect.right &&
-           clientY >= toggleRect.top &&
-           clientY <= toggleRect.bottom;
+  async maybeShowFlyout(video) {
+    let state = this.docState;
+
+    if (state.flyoutToggle) {
+      return;
+    }
+
+    let winUtils = this.content.windowUtils;
+
+    try {
+      winUtils.loadSheetUsingURIString(TOGGLE_STYLESHEET, winUtils.AGENT_SHEET);
+    } catch (e) {
+      // This method can fail with NS_ERROR_INVALID_ARG if the sheet is
+      // already loaded.
+      if (e.result != Cr.NS_ERROR_INVALID_ARG) {
+        throw e;
+      }
+    }
+
+    let container = this.content.document.createElement("div");
+    container.id = FLYOUT_TOGGLE_CONTAINER;
+
+    let toggle = this.content.document.createElement("button");
+    toggle.classList.add("picture-in-picture-toggle-button");
+    toggle.id = FLYOUT_TOGGLE_ID;
+
+    let icon = this.content.document.createElement("div");
+    icon.classList.add("icon");
+    toggle.appendChild(icon);
+
+    let label = this.content.document.createElement("span");
+    label.classList.add("label");
+    label.textContent = await this.flyoutLabel;
+    toggle.appendChild(label);
+    container.appendChild(toggle);
+    state.flyoutToggle =
+      this.content.document.insertAnonymousContent(container);
+
+    let { top, left, width } =
+      this.calculateTogglePosition(video, state.flyoutToggle, FLYOUT_TOGGLE_ID);
+
+    let styles = `
+      top: ${top}px;
+      left: ${left}px;
+    `;
+
+    let flyout = state.flyoutToggle;
+    flyout.setAttributeForElement(FLYOUT_TOGGLE_CONTAINER, "style", styles);
+    let flyoutAnim = flyout.setAnimationForElement(FLYOUT_TOGGLE_ID, [
+      { transform: `translateX(calc(100% - ${width}px))`, opacity: "0.2" },
+      { transform: `translateX(calc(100% - ${width}px))`, opacity: "0.8" },
+      { transform: "translateX(0)", opacity: "1" },
+    ], FLYOUT_ANIMATION_RUNTIME_MS);
+
+    await flyoutAnim.finished;
+
+    await new Promise(resolve => this.content.setTimeout(resolve,
+                                                         this.flyoutWaitMs));
+
+    flyoutAnim.reverse();
+    await flyoutAnim.finished;
+
+    this.hideFlyout();
+  }
+
+  /**
+   * Once the flyout has finished animating, or Picture-in-Picture has been
+   * requested, this function can be called to hide it.
+   */
+  hideFlyout() {
+    let state = this.docState;
+    let flyout = state.flyoutToggle;
+    if (flyout) {
+      flyout.setAttributeForElement(FLYOUT_TOGGLE_CONTAINER, "hidden", "true");
+    }
   }
 }
 
 class PictureInPictureChild extends ActorChild {
   static videoIsPlaying(video) {
     return !!(video.currentTime > 0 && !video.paused && !video.ended && video.readyState > 2);
   }
 
--- a/toolkit/actors/UAWidgetsChild.jsm
+++ b/toolkit/actors/UAWidgetsChild.jsm
@@ -9,17 +9,16 @@ var EXPORTED_SYMBOLS = ["UAWidgetsChild"
 const {ActorChild} = ChromeUtils.import("resource://gre/modules/ActorChild.jsm");
 const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 class UAWidgetsChild extends ActorChild {
   constructor(dispatcher) {
     super(dispatcher);
 
     this.widgets = new WeakMap();
-    this.prefsCache = new Map();
   }
 
   handleEvent(aEvent) {
     switch (aEvent.type) {
       case "UAWidgetSetupOrChange":
         this.setupOrNotifyWidget(aEvent.target);
         break;
       case "UAWidgetTeardown":
@@ -45,25 +44,21 @@ class UAWidgetsChild extends ActorChild 
         Cu.reportError(ex);
       }
     }
   }
 
   setupWidget(aElement) {
     let uri;
     let widgetName;
-    let prefKeys = [];
     switch (aElement.localName) {
       case "video":
       case "audio":
         uri = "chrome://global/content/elements/videocontrols.js";
         widgetName = "VideoControlsWidget";
-        prefKeys = [
-          "media.videocontrols.picture-in-picture.video-toggle.enabled",
-        ];
         break;
       case "input":
         uri = "chrome://global/content/elements/datetimebox.js";
         widgetName = "DateTimeBoxWidget";
         break;
       case "embed":
       case "object":
         uri = "chrome://global/content/elements/pluginProblem.js";
@@ -89,19 +84,17 @@ class UAWidgetsChild extends ActorChild 
     let isSystemPrincipal = aElement.nodePrincipal.isSystemPrincipal;
     let sandbox = isSystemPrincipal ?
       Object.create(null) : Cu.getUAWidgetScope(aElement.nodePrincipal);
 
     if (!sandbox[widgetName]) {
       Services.scriptloader.loadSubScript(uri, sandbox);
     }
 
-    let prefs = Cu.cloneInto(this.getPrefsForUAWidget(widgetName, prefKeys), sandbox);
-
-    let widget = new sandbox[widgetName](shadowRoot, prefs);
+    let widget = new sandbox[widgetName](shadowRoot);
     if (!isSystemPrincipal) {
       widget = widget.wrappedJSObject;
     }
     this.widgets.set(aElement, widget);
     try {
       widget.onsetup();
     } catch (ex) {
       Cu.reportError(ex);
@@ -117,37 +110,9 @@ class UAWidgetsChild extends ActorChild 
       try {
         widget.destructor();
       } catch (ex) {
         Cu.reportError(ex);
       }
     }
     this.widgets.delete(aElement);
   }
-
-  getPrefsForUAWidget(aWidgetName, aPrefKeys) {
-    let result = this.prefsCache.get(aWidgetName);
-    if (result) {
-      return result;
-    }
-
-    result = {};
-    for (let key of aPrefKeys) {
-      switch (Services.prefs.getPrefType(key)) {
-        case Ci.nsIPrefBranch.PREF_BOOL: {
-          result[key] = Services.prefs.getBoolPref(key);
-          break;
-        }
-        case Ci.nsIPrefBranch.PREF_INT: {
-          result[key] = Services.prefs.getIntPref(key);
-          break;
-        }
-        case Ci.nsIPrefBranch.PREF_STRING: {
-          result[key] = Services.prefs.getStringPref(key);
-          break;
-        }
-      }
-    }
-
-    this.prefsCache.set(aWidgetName, result);
-    return result;
-  }
 }
--- a/toolkit/content/widgets/videocontrols.js
+++ b/toolkit/content/widgets/videocontrols.js
@@ -7,19 +7,18 @@
 // This is a UA widget. It runs in per-origin UA widget scope,
 // to be loaded by UAWidgetsChild.jsm.
 
 /*
  * This is the class of entry. It will construct the actual implementation
  * according to the value of the "controls" property.
  */
 this.VideoControlsWidget = class {
-  constructor(shadowRoot, prefs) {
+  constructor(shadowRoot) {
     this.shadowRoot = shadowRoot;
-    this.prefs = prefs;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
     this.window = this.document.defaultView;
 
     this.isMobile = this.window.navigator.appVersion.includes("Android");
   }
 
   /*
@@ -47,35 +46,33 @@ this.VideoControlsWidget = class {
   switchImpl() {
     let newImpl;
     if (this.element.controls) {
       newImpl = VideoControlsImplWidget;
     } else if (this.isMobile) {
       newImpl = NoControlsMobileImplWidget;
     } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) {
       newImpl = NoControlsPictureInPictureImplWidget;
-    } else {
-      newImpl = NoControlsDesktopImplWidget;
     }
 
     // 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, this.prefs);
+      this.impl = new newImpl(this.shadowRoot);
       this.impl.onsetup();
     } else {
       this.impl = undefined;
     }
   }
 
   destructor() {
     if (!this.impl) {
@@ -87,19 +84,18 @@ this.VideoControlsWidget = class {
   }
 
   static isPictureInPictureVideo(someVideo) {
     return someVideo.isCloningElementVisually;
   }
 };
 
 this.VideoControlsImplWidget = class {
-  constructor(shadowRoot, prefs) {
+  constructor(shadowRoot) {
     this.shadowRoot = shadowRoot;
-    this.prefs = prefs;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
     this.window = this.document.defaultView;
   }
 
   onsetup() {
     this.generateContent();
 
@@ -264,20 +260,16 @@ this.VideoControlsImplWidget = class {
           this.startFadeOut(this.clickToPlay, true);
           this.statusIcon.setAttribute("type", "error");
           this.updateErrorText();
           this.setupStatusFader(true);
         } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) {
           this.setShowPictureInPictureMessage(true);
         }
 
-        if (!this.pipToggleEnabled || this.isShowingPictureInPictureMessage) {
-          this.pictureInPictureToggleButton.setAttribute("hidden", true);
-        }
-
         let adjustableControls = [
           ...this.prioritizedControls,
           this.controlBar,
           this.clickToPlay,
         ];
 
         let throwOnGet = {
           get() {
@@ -670,19 +662,16 @@ this.VideoControlsImplWidget = class {
               case this.textTrackList:
                 const index = +aEvent.originalTarget.getAttribute("index");
                 this.changeTextTrack(index);
                 break;
               case this.videocontrols:
                 // Prevent any click event within media controls from dispatching through to video.
                 aEvent.stopPropagation();
                 break;
-              case this.pictureInPictureToggleButton:
-                this.video.togglePictureInPicture();
-                break;
             }
             break;
           case "dblclick":
             this.toggleFullscreen();
             break;
           case "resizevideocontrols":
             // Since this event come from the layout, this is the only place
             // we are sure of that probing into layout won't trigger or force
@@ -1949,28 +1938,23 @@ this.VideoControlsImplWidget = class {
           if (this.clickToPlay.hidden && !this.video.played.length && this.video.paused) {
             this.clickToPlay.hiddenByAdjustment = false;
           }
           this.clickToPlay.style.width = `${clickToPlayScaledSize}px`;
           this.clickToPlay.style.height = `${clickToPlayScaledSize}px`;
         }
       },
 
-      get pipToggleEnabled() {
-        return this.prefs["media.videocontrols.picture-in-picture.video-toggle.enabled"];
-      },
-
-      init(shadowRoot, prefs) {
+      init(shadowRoot) {
         this.shadowRoot = shadowRoot;
         this.video = this.installReflowCallValidator(shadowRoot.host);
         this.videocontrols = this.installReflowCallValidator(shadowRoot.firstChild);
         this.document = this.videocontrols.ownerDocument;
         this.window = this.document.defaultView;
         this.shadowRoot = shadowRoot;
-        this.prefs = prefs;
 
         this.controlsContainer = this.shadowRoot.getElementById("controlsContainer");
         this.statusIcon = this.shadowRoot.getElementById("statusIcon");
         this.controlBar = this.shadowRoot.getElementById("controlBar");
         this.playButton = this.shadowRoot.getElementById("playButton");
         this.controlBarSpacer = this.shadowRoot.getElementById("controlBarSpacer");
         this.muteButton = this.shadowRoot.getElementById("muteButton");
         this.volumeStack = this.shadowRoot.getElementById("volumeStack");
@@ -1986,18 +1970,16 @@ this.VideoControlsImplWidget = class {
         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");
-        this.pictureInPictureToggleButton =
-          this.shadowRoot.getElementById("pictureInPictureToggleButton");
 
         if (this.positionDurationBox) {
           this.durationSpan = this.positionDurationBox.getElementsByTagName("span")[0];
         }
 
         let isMobile = this.window.navigator.appVersion.includes("Android");
         if (isMobile) {
           this.controlsContainer.classList.add("mobile");
@@ -2075,18 +2057,16 @@ this.VideoControlsImplWidget = class {
           // isn't fired when the input value before/after dragging are the same. (bug 1328061)
           { el: this.scrubber, type: "mouseup" },
           { el: this.volumeControl, type: "input" },
           { el: this.video.textTracks, type: "addtrack" },
           { el: this.video.textTracks, type: "removetrack" },
           { el: this.video.textTracks, type: "change" },
 
           { el: this.video, type: "media-videoCasting", touchOnly: true },
-
-          { el: this.pictureInPictureToggleButton, type: "click" },
         ];
 
         for (let { el, type, nonTouchOnly = false, touchOnly = false,
                    mozSystemGroup = true, capture = false } of this.controlsEvents) {
           if ((this.isTouchControls && nonTouchOnly) ||
               (!this.isTouchControls && touchOnly)) {
             continue;
           }
@@ -2225,17 +2205,17 @@ this.VideoControlsImplWidget = class {
         // the controls to remain visible. this.controlsTimeout is a full
         // 5s, which feels too long after the transition.
         if (this.video.currentTime !== 0) {
           this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS);
         }
       },
     };
 
-    this.Utils.init(this.shadowRoot, this.prefs);
+    this.Utils.init(this.shadowRoot);
     if (this.Utils.isTouchControls) {
       this.TouchUtils.init(this.shadowRoot, this.Utils);
     }
     this.shadowRoot.firstChild.dispatchEvent(new this.window.CustomEvent("VideoBindingAttached"));
 
     this._setupEventListeners();
   }
 
@@ -2268,20 +2248,16 @@ this.VideoControlsImplWidget = class {
           </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>
 
-            <button id="pictureInPictureToggleButton" class="pictureInPictureToggleButton">
-              <div id="pictureInPictureToggleIcon" class="pictureInPictureToggleIcon"></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">
@@ -2487,19 +2463,18 @@ this.NoControlsMobileImplWidget = class 
           </div>
         </div>
       </div>`, "application/xml");
     this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
   }
 };
 
 this.NoControlsPictureInPictureImplWidget = class {
-  constructor(shadowRoot, prefs) {
+  constructor(shadowRoot) {
     this.shadowRoot = shadowRoot;
-    this.prefs = prefs;
     this.element = shadowRoot.host;
     this.document = this.element.ownerDocument;
     this.window = this.document.defaultView;
   }
 
   onsetup() {
     this.generateContent();
   }
@@ -2529,76 +2504,8 @@ this.NoControlsPictureInPictureImplWidge
             <span class="statusLabel" id="pictureInPicture">&status.pictureInPicture;</span>
           </div>
           <div class="controlsOverlay stackItem"></div>
         </div>
       </div>`, "application/xml");
     this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
   }
 };
-
-this.NoControlsDesktopImplWidget = class {
-  constructor(shadowRoot, prefs) {
-    this.shadowRoot = shadowRoot;
-    this.element = shadowRoot.host;
-    this.document = this.element.ownerDocument;
-    this.window = this.document.defaultView;
-    this.prefs = prefs;
-  }
-
-  onsetup() {
-    this.generateContent();
-
-    this.Utils = {
-      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");
-
-        if (!this.pipToggleEnabled) {
-          this.pictureInPictureToggleButton.setAttribute("hidden", true);
-        }
-      },
-
-      get pipToggleEnabled() {
-        return this.prefs["media.videocontrols.picture-in-picture.video-toggle.enabled"];
-      },
-    };
-    this.Utils.init(this.shadowRoot, this.prefs);
-  }
-
-  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="controlsOverlay stackItem">
-            <button id="pictureInPictureToggleButton" class="pictureInPictureToggleButton">
-              <div id="pictureInPictureToggleIcon" class="pictureInPictureToggleIcon"></div>
-            </button>
-          </div>
-        </div>
-      </div>`, "application/xml");
-    this.shadowRoot.importNodeAndAppendChildAt(this.shadowRoot, parserDoc.documentElement, true);
-  }
-};
new file mode 100644
--- /dev/null
+++ b/toolkit/locales/en-US/toolkit/global/videocontrols.ftl
@@ -0,0 +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/.
+
+### These strings are used in the video controls.
+
+# This string is used when displaying the Picture-in-Picture "flyout" toggle.
+# The "flyout" toggle is a variation of the Picture-in-Picture video toggle that
+# appears in a ribbon over top of <video> elements when Picture-in-Picture is
+# enabled. This variation only appears on the first <video> that's displayed to
+# a user on a page. It animates out, displaying this string, and after 5
+# seconds, animates away again.
+picture-in-picture-flyout-toggle = Picture-in-Picture
--- a/toolkit/themes/shared/jar.inc.mn
+++ b/toolkit/themes/shared/jar.inc.mn
@@ -107,10 +107,11 @@ toolkit.jar:
   skin/classic/global/plugins/plugin.svg                    (../../shared/plugins/plugin.svg)
   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)
   skin/classic/global/pictureinpicture/player.css           (../../shared/pictureinpicture/player.css)
+  skin/classic/global/pictureinpicture/toggle.css           (../../shared/pictureinpicture/toggle.css)
   skin/classic/global/media/pictureinpicture.svg            (../../shared/media/pictureinpicture.svg)
 
--- a/toolkit/themes/shared/media/videocontrols.css
+++ b/toolkit/themes/shared/media/videocontrols.css
@@ -23,20 +23,16 @@
 .controlsContainer {
   --clickToPlay-size: 48px;
   --button-size: 30px;
   --timer-size: 40px;
   --timer-long-size: 60px;
   --track-size: 5px;
   --thumb-size: 13px;
   --label-font-size: 13px;
-  --pip-toggle-bgcolor: rgb(0, 96, 223);
-  --pip-toggle-text-and-icon-color: rgb(255, 255, 255);
-  --pip-toggle-padding: 5px;
-  --pip-toggle-icon-width-height: 16px;
 }
 .controlsContainer.touch {
   --clickToPlay-size: 64px;
   --button-size: 40px;
   --timer-size: 52px;
   --timer-long-size: 78px;
   --track-size: 7px;
   --thumb-size: 16px;
@@ -64,18 +60,17 @@
 .touch .controlBar {
   /* Do not delete: these variables are accessed by JavaScript directly.
      see videocontrols.js and search for |-width|. */
   --scrubberStack-width: 84px;
   --volumeStack-width: 64px;
 }
 
 .controlsContainer [hidden],
-.controlBar[hidden],
-.pictureInPictureToggleButton[hidden] {
+.controlBar[hidden] {
   display: none;
 }
 
 .controlBar[size="hidden"] {
   display: none;
 }
 
 .controlsSpacer[hideCursor] {
@@ -433,54 +428,16 @@
   min-width: 84px;
   min-height: 84px;
   background-image: url(chrome://global/skin/media/pictureinpicture.svg);
   -moz-context-properties: fill, stroke;
   fill: #fff;
   stroke: #fff;
 }
 
-.pictureInPictureToggleButton {
-  display: flex;
-  -moz-appearance: none;
-  position: absolute;
-  background-color: var(--pip-toggle-bgcolor);
-  color: var(--pip-toggle-text-and-icon-color);
-  border: 0;
-  padding: var(--pip-toggle-padding);
-  right: 0;
-  top: 50%;
-  transform: translateY(-50%);
-  transition: opacity 160ms linear;
-  min-width: max-content;
-  pointer-events: auto;
-  opacity: 0;
-}
-
-.pictureInPictureToggleIcon {
-  display: inline-block;
-  background-image: url(chrome://global/skin/media/pictureinpicture.svg);
-  background-position: center left;
-  background-repeat: no-repeat;
-  -moz-context-properties: fill, stroke;
-  fill: var(--pip-toggle-text-and-icon-color);
-  stroke: var(--pip-toggle-text-and-icon-color);
-  width: var(--pip-toggle-icon-width-height);
-  height: var(--pip-toggle-icon-width-height);
-  min-width: max-content;
-}
-
-.controlsOverlay:hover > .pictureInPictureToggleButton {
-  opacity: 0.8;
-}
-
-.controlsOverlay:hover > .pictureInPictureToggleButton:hover {
-  opacity: 1;
-}
-
 /* 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);
   background-repeat: no-repeat;
   background-position: 54% 50%;
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/pictureinpicture/toggle.css
@@ -0,0 +1,83 @@
+/**
+ * We add the #picture-in-picture-flyout-container and
+ * #picture-in-picture-toggle IDs here so that it's easier to read these
+ * property values in script, since they're AnonymousContent, and we need
+ * IDs and can't use classes to query AnonymousContent property values.
+ */
+#picture-in-picture-flyout-container:-moz-native-anonymous,
+#picture-in-picture-toggle:-moz-native-anonymous,
+.picture-in-picture-toggle-button:-moz-native-anonymous {
+  --pip-toggle-bgcolor: rgb(0, 96, 223);
+  --pip-toggle-text-and-icon-color: rgb(255, 255, 255);
+  --pip-toggle-padding: 5px;
+  --pip-toggle-icon-width-height: 16px;
+}
+
+.picture-in-picture-toggle-button:-moz-native-anonymous {
+  -moz-appearance: none;
+  display: flex;
+  position: absolute;
+  background-color: var(--pip-toggle-bgcolor);
+  border: 0;
+  padding: var(--pip-toggle-padding);
+  color: var(--pip-toggle-text-and-icon-color);
+  transform: translateX(0);
+  transition: transform 350ms linear;
+  min-width: max-content;
+  pointer-events: auto;
+  opacity: 0.8;
+}
+
+.picture-in-picture-toggle-button:-moz-native-anonymous:hover,
+.picture-in-picture-toggle-button:-moz-native-anonymous:active {
+  opacity: 1;
+  background-color: var(--pip-toggle-bgcolor);
+  color: var(--pip-toggle-text-and-icon-color);
+  padding: var(--pip-toggle-padding);
+}
+
+#picture-in-picture-flyout-container[hidden]:-moz-native-anonymous,
+.picture-in-picture-toggle-button[hidden]:-moz-native-anonymous {
+  display: none;
+}
+
+.picture-in-picture-toggle-button:-moz-native-anonymous > .icon {
+  display: inline-block;
+  background-image: url(chrome://global/skin/media/pictureinpicture.svg);
+  background-position: center left;
+  background-repeat: no-repeat;
+  -moz-context-properties: fill, stroke;
+  fill: var(--pip-toggle-text-and-icon-color);
+  stroke: var(--pip-toggle-text-and-icon-color);
+  width: var(--pip-toggle-icon-width-height);
+  height: var(--pip-toggle-icon-width-height);
+  min-width: max-content;
+  pointer-events: none;
+}
+
+.picture-in-picture-toggle-button:-moz-native-anonymous > .label {
+  margin-left: var(--pip-toggle-padding);
+  min-width: max-content;
+  pointer-events: none;
+}
+
+#picture-in-picture-flyout-container:-moz-native-anonymous {
+  position: absolute;
+  /**
+   * A higher z-index makes sure that the flyout always appears on top of the
+   * other toggle, so that we avoid seeing double-toggles.
+   */
+  z-index: 2;
+  overflow: hidden;
+  /**
+   * This places the container for the flyout in the position where the flyout
+   * eventually ends up. This, coupled with the overflow: hidden, gives the
+   * effect that the flyout is sliding out from the edge of the video.
+   */
+  transform: translateX(calc(-100% + var(--pip-toggle-icon-width-height) + 2 * var(--pip-toggle-padding)));
+}
+
+#picture-in-picture-flyout-container:-moz-native-anonymous > .picture-in-picture-toggle-button {
+  position: relative;
+  opacity: 1;
+}