summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/pictureinpicture')
-rw-r--r--toolkit/components/pictureinpicture/PictureInPicture.jsm827
-rw-r--r--toolkit/components/pictureinpicture/PictureInPictureControls.jsm44
-rw-r--r--toolkit/components/pictureinpicture/content/player.js669
-rw-r--r--toolkit/components/pictureinpicture/content/player.xhtml57
-rw-r--r--toolkit/components/pictureinpicture/docs/PiP-diagram.svg4
-rw-r--r--toolkit/components/pictureinpicture/docs/index.rst190
-rw-r--r--toolkit/components/pictureinpicture/jar.mn8
-rw-r--r--toolkit/components/pictureinpicture/moz.build21
-rw-r--r--toolkit/components/pictureinpicture/tests/.eslintrc.js5
-rw-r--r--toolkit/components/pictureinpicture/tests/browser.ini87
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePipPause.js68
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePlayer.js48
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closeTab.js25
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_contextMenu.js238
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js271
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_durationChange.js61
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js66
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_fullscreen.js142
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js83
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js59
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js97
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_multiPip.js232
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js45
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_playerControls.js91
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js84
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js31
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_resizeVideo.js263
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_reversePiP.js145
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js362
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js67
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_showMessage.js29
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js210
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js49
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js47
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js58
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js17
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js202
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js42
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js16
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js16
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePolicies.js126
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleSimple.js18
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js33
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js33
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_videoSelection.js106
-rw-r--r--toolkit/components/pictureinpicture/tests/click-event-helper.js26
-rw-r--r--toolkit/components/pictureinpicture/tests/head.js835
-rw-r--r--toolkit/components/pictureinpicture/tests/short.mp4bin0 -> 38713 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-button-overlay.html81
-rw-r--r--toolkit/components/pictureinpicture/tests/test-media-stream.html25
-rw-r--r--toolkit/components/pictureinpicture/tests/test-opaque-overlay.html51
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-iframe.html27
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-sound.html20
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page.html30
-rw-r--r--toolkit/components/pictureinpicture/tests/test-pointer-events-none.html21
-rw-r--r--toolkit/components/pictureinpicture/tests/test-reversed.html19
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html51
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html46
-rw-r--r--toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html46
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-cropped.mp4bin0 -> 36502 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-long.mp4bin0 -> 344085 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-selection.html22
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video-vertical.mp4bin0 -> 36502 bytes
-rw-r--r--toolkit/components/pictureinpicture/tests/test-video.mp4bin0 -> 242969 bytes
67 files changed, 6904 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.jsm b/toolkit/components/pictureinpicture/PictureInPicture.jsm
new file mode 100644
index 0000000000..f1f17fd3f0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/PictureInPicture.jsm
@@ -0,0 +1,827 @@
+/* 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 = [
+ "PictureInPicture",
+ "PictureInPictureParent",
+ "PictureInPictureToggleParent",
+ "PictureInPictureLauncherParent",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
+var PLAYER_FEATURES =
+ "chrome,titlebar=yes,alwaysontop,lockaspectratio,resizable";
+/* Don't use dialog on Gtk as it adds extra border and titlebar to PIP window */
+if (!AppConstants.MOZ_WIDGET_GTK) {
+ PLAYER_FEATURES += ",dialog";
+}
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled";
+const MULTI_PIP_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.allow-multiple";
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+
+/**
+ * If closing the Picture-in-Picture player window occurred for a reason that
+ * we can easily detect (user clicked on the close button, originating tab unloaded,
+ * user clicked on the unpip button), that will be stashed in gCloseReasons so that
+ * we can note it in Telemetry when the window finally unloads.
+ */
+let gCloseReasons = new WeakMap();
+
+/**
+ * Tracks the number of currently open player windows for Telemetry tracking
+ */
+let gCurrentPlayerCount = 0;
+
+/**
+ * To differentiate windows in the Telemetry Event Log, each Picture-in-Picture
+ * player window is given a unique ID.
+ */
+let gNextWindowID = 0;
+
+class PictureInPictureLauncherParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "PictureInPicture:Request": {
+ let videoData = aMessage.data;
+ PictureInPicture.handlePictureInPictureRequest(this.manager, videoData);
+ break;
+ }
+ }
+ }
+}
+
+class PictureInPictureToggleParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ let browsingContext = aMessage.target.browsingContext;
+ let browser = browsingContext.top.embedderElement;
+ switch (aMessage.name) {
+ case "PictureInPicture:OpenToggleContextMenu": {
+ let win = browser.ownerGlobal;
+ PictureInPicture.openToggleContextMenu(win, aMessage.data);
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This module is responsible for creating a Picture in Picture window to host
+ * a clone of a video element running in web content.
+ */
+class PictureInPictureParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "PictureInPicture:Resize": {
+ let videoData = aMessage.data;
+ PictureInPicture.resizePictureInPictureWindow(videoData, this);
+ break;
+ }
+ case "PictureInPicture:Close": {
+ /**
+ * Content has requested that its Picture in Picture window go away.
+ */
+ let reason = aMessage.data.reason;
+
+ if (PictureInPicture.isMultiPipEnabled) {
+ PictureInPicture.closeSinglePipWindow({ reason, actorRef: this });
+ } else {
+ PictureInPicture.closeAllPipWindows({ reason });
+ }
+ break;
+ }
+ case "PictureInPicture:Playing": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.setIsPlayingState(true);
+ }
+ break;
+ }
+ case "PictureInPicture:Paused": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.setIsPlayingState(false);
+ }
+ break;
+ }
+ case "PictureInPicture:Muting": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.setIsMutedState(true);
+ }
+ break;
+ }
+ case "PictureInPicture:Unmuting": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.setIsMutedState(false);
+ }
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This module is responsible for creating a Picture in Picture window to host
+ * a clone of a video element running in web content.
+ */
+
+var PictureInPicture = {
+ // Maps PictureInPictureParent actors to their corresponding PiP player windows
+ weakPipToWin: new WeakMap(),
+
+ // Maps PiP player windows to their originating content's browser
+ weakWinToBrowser: new WeakMap(),
+
+ /**
+ * Returns the player window if one exists and if it hasn't yet been closed.
+ *
+ * @param pipActorRef (PictureInPictureParent)
+ * Reference to the calling PictureInPictureParent actor
+ *
+ * @return {DOM Window} the player window if it exists and is not in the
+ * process of being closed. Returns null otherwise.
+ */
+ getWeakPipPlayer(pipActorRef) {
+ let playerWin = this.weakPipToWin.get(pipActorRef);
+ if (!playerWin || playerWin.closed) {
+ return null;
+ }
+ return playerWin;
+ },
+
+ /**
+ * Called when the browser UI handles the View:PictureInPicture command via
+ * the keyboard.
+ */
+ onCommand(event) {
+ if (!Services.prefs.getBoolPref(PIP_ENABLED_PREF, false)) {
+ return;
+ }
+
+ let win = event.target.ownerGlobal;
+ let browser = win.gBrowser.selectedBrowser;
+ let actor = browser.browsingContext.currentWindowGlobal.getActor(
+ "PictureInPictureLauncher"
+ );
+ actor.sendAsyncMessage("PictureInPicture:KeyToggle");
+ },
+
+ async focusTabAndClosePip(window, pipActor) {
+ let browser = this.weakWinToBrowser.get(window);
+ if (!browser) {
+ return;
+ }
+
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ gBrowser.selectedTab = tab;
+ await this.closeSinglePipWindow({ reason: "unpip", actorRef: pipActor });
+ },
+
+ /**
+ * Remove attribute which enables pip icon in tab
+ *
+ * @param window {Window}
+ * A PictureInPicture player's window, used to resolve the player's
+ * associated originating content browser
+ */
+ clearPipTabIcon(window) {
+ const browser = this.weakWinToBrowser.get(window);
+ if (!browser) {
+ return;
+ }
+
+ // see if no other pip windows are open for this content browser
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (
+ win !== window &&
+ this.weakWinToBrowser.has(win) &&
+ this.weakWinToBrowser.get(win) === browser
+ ) {
+ return;
+ }
+ }
+
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (tab) {
+ tab.removeAttribute("pictureinpicture");
+ }
+ },
+
+ /**
+ * Closes and waits for passed PiP player window to finish closing.
+ *
+ * @param pipWin {Window}
+ * Player window to close
+ */
+ async closePipWindow(pipWin) {
+ if (pipWin.closed) {
+ return;
+ }
+ let closedPromise = new Promise(resolve => {
+ pipWin.addEventListener("unload", resolve, { once: true });
+ });
+ pipWin.close();
+ await closedPromise;
+ },
+
+ /**
+ * Closes a single PiP window. Used exclusively in conjunction with support
+ * for multiple PiP windows
+ *
+ * @param {Object} closeData
+ * Additional data required to complete a close operation on a PiP window
+ * @param {PictureInPictureParent} closeData.actorRef
+ * The PictureInPictureParent actor associated with the PiP window being closed
+ * @param {string} closeData.reason
+ * The reason for closing this PiP window
+ */
+ async closeSinglePipWindow(closeData) {
+ const { reason, actorRef } = closeData;
+ const win = this.getWeakPipPlayer(actorRef);
+ if (!win) {
+ return;
+ }
+
+ await this.closePipWindow(win);
+ gCloseReasons.set(win, reason);
+ },
+
+ /**
+ * Find and close any pre-existing Picture in Picture windows. Used exclusively
+ * when multiple PiP window support is turned off. All windows can be closed because it
+ * is assumed that only 1 window is open when it is called.
+ *
+ * @param {Object} closeData
+ * Additional data required to complete a close operation on a PiP window
+ * @param {string} closeData.reason
+ * The reason why this PiP is being closed
+ */
+ async closeAllPipWindows(closeData) {
+ const { reason } = closeData;
+
+ // This uses an enumerator, but there really should only be one of
+ // these things.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (win.closed) {
+ continue;
+ }
+ let closedPromise = new Promise(resolve => {
+ win.addEventListener("unload", resolve, { once: true });
+ });
+ gCloseReasons.set(win, reason);
+ win.close();
+ await closedPromise;
+ }
+ },
+
+ /**
+ * A request has come up from content to open a Picture in Picture
+ * window.
+ *
+ * @param wgp (WindowGlobalParent)
+ * The WindowGlobalParent that is requesting the Picture in Picture
+ * window.
+ *
+ * @param videoData (object)
+ * An object containing the following properties:
+ *
+ * videoHeight (int):
+ * The preferred height of the video.
+ *
+ * videoWidth (int):
+ * The preferred width of the video.
+ *
+ * @returns Promise
+ * Resolves once the Picture in Picture window has been created, and
+ * the player component inside it has finished loading.
+ */
+ async handlePictureInPictureRequest(wgp, videoData) {
+ if (!this.isMultiPipEnabled) {
+ // If there's a pre-existing PiP window, close it first if multiple
+ // pips are disabled
+ await this.closeAllPipWindows({ reason: "new-pip" });
+
+ gCurrentPlayerCount = 1;
+ } else {
+ // track specific number of open pip players if multi pip is
+ // enabled
+
+ gCurrentPlayerCount += 1;
+ }
+
+ Services.telemetry.scalarSetMaximum(
+ "pictureinpicture.most_concurrent_players",
+ gCurrentPlayerCount
+ );
+
+ let browser = wgp.browsingContext.top.embedderElement;
+ let parentWin = browser.ownerGlobal;
+
+ let win = await this.openPipWindow(parentWin, videoData);
+ win.setIsPlayingState(videoData.playing);
+ 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(), wgp, videoData.videoRef);
+ gNextWindowID++;
+
+ this.weakWinToBrowser.set(win, browser);
+
+ 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(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
+ window
+ );
+
+ let reason = gCloseReasons.get(window) || "other";
+ Services.telemetry.keyedScalarAdd(
+ "pictureinpicture.closed_method",
+ reason,
+ 1
+ );
+ gCurrentPlayerCount -= 1;
+ // Saves the location of the Picture in Picture window
+ this.savePosition(window);
+ this.clearPipTabIcon(window);
+ },
+
+ /**
+ * Open a Picture in Picture window on the same screen as parentWin,
+ * sized based on the information in videoData.
+ *
+ * @param parentWin (chrome window)
+ * The window hosting the browser that requested the Picture in
+ * Picture window.
+ *
+ * @param videoData (object)
+ * An object containing the following properties:
+ *
+ * videoHeight (int):
+ * The preferred height of the video.
+ *
+ * videoWidth (int):
+ * The preferred width of the video.
+ *
+ * @param actorReference (PictureInPictureParent)
+ * Reference to the calling PictureInPictureParent
+ *
+ * @returns Promise
+ * Resolves once the window has opened and loaded the player component.
+ */
+ async openPipWindow(parentWin, videoData) {
+ let { top, left, width, height } = this.fitToScreen(parentWin, videoData);
+
+ let features =
+ `${PLAYER_FEATURES},top=${top},left=${left},` +
+ `outerWidth=${width},outerHeight=${height}`;
+
+ let pipWindow = Services.ww.openWindow(
+ parentWin,
+ PLAYER_URI,
+ null,
+ features,
+ null
+ );
+
+ TelemetryStopwatch.start(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
+ pipWindow,
+ {
+ inSeconds: true,
+ }
+ );
+
+ return new Promise(resolve => {
+ pipWindow.addEventListener(
+ "load",
+ () => {
+ resolve(pipWindow);
+ },
+ { once: true }
+ );
+ });
+ },
+
+ /**
+ * This function tries to restore the last known Picture-in-Picture location
+ * and size. If those values are unknown or offscreen, then a default
+ * location and size is used.
+ *
+ * @param requestingWin (chrome window|player window)
+ * The window hosting the browser that requested the Picture in
+ * Picture window. If this is an existing player window then the returned
+ * player size and position will be determined based on the existing
+ * player window's size and position.
+ *
+ * @param videoData (object)
+ * An object containing the following properties:
+ *
+ * videoHeight (int):
+ * The preferred height of the video.
+ *
+ * videoWidth (int):
+ * The preferred width of the video.
+ *
+ * @returns (object)
+ * The size and position for the player window.
+ *
+ * top (int):
+ * The top position for the player window.
+ *
+ * left (int):
+ * The left position for the player window.
+ *
+ * width (int):
+ * The width of the player window.
+ *
+ * height (int):
+ * The height of the player window.
+ */
+ fitToScreen(requestingWin, videoData) {
+ let { videoHeight, videoWidth } = videoData;
+
+ const isPlayer = requestingWin.document.location.href == PLAYER_URI;
+
+ let top, left, width, height;
+ if (isPlayer) {
+ // requestingWin is a PiP player, conserve its dimensions in this case
+ left = requestingWin.screenX;
+ top = requestingWin.screenY;
+ width = requestingWin.innerWidth;
+ height = requestingWin.innerHeight;
+ } else {
+ // requestingWin is a content window, load last PiP's dimensions
+ ({ top, left, width, height } = this.loadPosition());
+ }
+
+ // Check that previous location and size were loaded
+ if (!isNaN(top) && !isNaN(left) && !isNaN(width) && !isNaN(height)) {
+ // Center position of PiP window
+ let centerX = left + width / 2;
+ let centerY = top + height / 2;
+
+ // Get the screen of the last PiP using the center of the PiP
+ // window to check.
+ // PiP screen will be the default screen if the center was
+ // not on a screen.
+ let PiPScreen = this.getWorkingScreen(centerX, centerY);
+
+ // We have the screen, now we will get the dimensions of the screen
+ let [
+ PiPScreenLeft,
+ PiPScreenTop,
+ PiPScreenWidth,
+ PiPScreenHeight,
+ ] = this.getAvailScreenSize(PiPScreen);
+
+ // Check that the center of the last PiP location is within the screen limits
+ // If it's not, then we will use the default size and position
+ if (
+ PiPScreenLeft <= centerX &&
+ centerX <= PiPScreenLeft + PiPScreenWidth &&
+ PiPScreenTop <= centerY &&
+ centerY <= PiPScreenTop + PiPScreenHeight
+ ) {
+ let oldWidth = width;
+
+ // The new PiP window will keep the height of the old
+ // PiP window and adjust the width to the correct ratio
+ width = Math.round((height * videoWidth) / videoHeight);
+
+ // Minimum window size on Windows is 136
+ if (AppConstants.platform == "win") {
+ width = 136 > width ? 136 : width;
+ }
+
+ // WIGGLE_ROOM allows the PiP window to be within 5 pixels of the right
+ // side of the screen to stay snapped to the right side
+ const WIGGLE_ROOM = 5;
+ // If the PiP window was right next to the right side of the screen
+ // then move the PiP window to the right the same distance that
+ // the width changes from previous width to current width
+ let rightScreen = PiPScreenLeft + PiPScreenWidth;
+ let distFromRight = rightScreen - (left + width);
+ if (
+ 0 < distFromRight &&
+ distFromRight <= WIGGLE_ROOM + (oldWidth - width)
+ ) {
+ left += distFromRight;
+ }
+
+ // Checks if some of the PiP window is off screen and
+ // if so it will adjust to move everything on screen
+ if (left < PiPScreenLeft) {
+ // off the left of the screen
+ // slide right
+ left += PiPScreenLeft - left;
+ }
+ if (top < PiPScreenTop) {
+ // off the top of the screen
+ // slide down
+ top += PiPScreenTop - top;
+ }
+ if (left + width > PiPScreenLeft + PiPScreenWidth) {
+ // off the right of the screen
+ // slide left
+ left += PiPScreenLeft + PiPScreenWidth - left - width;
+ }
+ if (top + height > PiPScreenTop + PiPScreenHeight) {
+ // off the bottom of the screen
+ // slide up
+ top += PiPScreenTop + PiPScreenHeight - top - height;
+ }
+ return { top, left, width, height };
+ }
+ }
+
+ // We don't have the size or position of the last PiP window, so fall
+ // back to calculating the default location.
+ let screen = this.getWorkingScreen(
+ requestingWin.screenX,
+ requestingWin.screenY,
+ requestingWin.innerWidth,
+ requestingWin.innerHeight
+ );
+ let [
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight,
+ ] = this.getAvailScreenSize(screen);
+
+ // The Picture in Picture window will be a maximum of a quarter of
+ // the screen height, and a third of the screen width.
+ const MAX_HEIGHT = screenHeight / 4;
+ const MAX_WIDTH = screenWidth / 3;
+
+ width = videoWidth;
+ height = videoHeight;
+ let aspectRatio = videoWidth / videoHeight;
+
+ if (videoHeight > MAX_HEIGHT || videoWidth > MAX_WIDTH) {
+ // We're bigger than the max.
+ // Take the largest dimension and clamp it to the associated max.
+ // Recalculate the other dimension to maintain aspect ratio.
+ if (videoWidth >= videoHeight) {
+ // We're clamping the width, so the height must be adjusted to match
+ // the original aspect ratio. Since aspect ratio is width over height,
+ // that means we need to _divide_ the MAX_WIDTH by the aspect ratio to
+ // calculate the appropriate height.
+ width = MAX_WIDTH;
+ height = Math.round(MAX_WIDTH / aspectRatio);
+ } else {
+ // We're clamping the height, so the width must be adjusted to match
+ // the original aspect ratio. Since aspect ratio is width over height,
+ // this means we need to _multiply_ the MAX_HEIGHT by the aspect ratio
+ // to calculate the appropriate width.
+ height = MAX_HEIGHT;
+ width = Math.round(MAX_HEIGHT * aspectRatio);
+ }
+ }
+
+ // Now that we have the dimensions of the video, we need to figure out how
+ // to position it in the bottom right corner. Since we know the width of the
+ // available rect, we need to subtract the dimensions of the window we're
+ // opening to get the top left coordinates that openWindow expects.
+ //
+ // In event that the user has multiple displays connected, we have to
+ // calculate the top-left coordinate of the new window in absolute
+ // coordinates that span the entire display space, since this is what the
+ // openWindow expects for its top and left feature values.
+ //
+ // The screenWidth and screenHeight values only tell us the available
+ // dimensions on the screen that the parent window is on. We add these to
+ // the screenLeft and screenTop values, which tell us where this screen is
+ // located relative to the "origin" in absolute coordinates.
+ let isRTL = Services.locale.isAppLocaleRTL;
+ left = isRTL ? screenLeft : screenLeft + screenWidth - width;
+ top = screenTop + screenHeight - height;
+
+ return { top, left, width, height };
+ },
+
+ resizePictureInPictureWindow(videoData, actorRef) {
+ let win = this.getWeakPipPlayer(actorRef);
+
+ if (!win) {
+ return;
+ }
+
+ let { top, left, width, height } = this.fitToScreen(win, videoData);
+ win.resizeTo(width, height);
+ win.moveTo(left, top);
+ },
+
+ openToggleContextMenu(window, data) {
+ let document = window.document;
+ let popup = document.getElementById("pictureInPictureToggleContextMenu");
+
+ // We synthesize a new MouseEvent to propagate the inputSource to the
+ // subsequently triggered popupshowing event.
+ let newEvent = document.createEvent("MouseEvent");
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ data.screenX,
+ data.screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null,
+ 0,
+ data.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+ },
+
+ hideToggle() {
+ Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false);
+ },
+
+ /**
+ * This function takes a screen and will return the left, top, width and
+ * height of the screen
+ * @param screen
+ * The screen we need to get the sizec and coordinates of
+ *
+ * @returns array
+ * Size and location of screen
+ *
+ * screenLeft.value (int):
+ * The left position for the screen.
+ *
+ * screenTop.value (int):
+ * The top position for the screen.
+ *
+ * screenWidth.value (int):
+ * The width of the screen.
+ *
+ * screenHeight.value (int):
+ * The height of the screen.
+ */
+ getAvailScreenSize(screen) {
+ let screenLeft = {},
+ screenTop = {},
+ screenWidth = {},
+ screenHeight = {};
+ screen.GetAvailRectDisplayPix(
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight
+ );
+ let fullLeft = {},
+ fullTop = {},
+ fullWidth = {},
+ fullHeight = {};
+ screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight);
+
+ // We have to divide these dimensions by the CSS scale factor for the
+ // display in order for the video to be positioned correctly on displays
+ // that are not at a 1.0 scaling.
+ let scaleFactor = screen.contentsScaleFactor / screen.defaultCSSScaleFactor;
+ screenWidth.value *= scaleFactor;
+ screenHeight.value *= scaleFactor;
+ screenLeft.value =
+ (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value;
+ screenTop.value =
+ (screenTop.value - fullTop.value) * scaleFactor + fullTop.value;
+
+ return [
+ screenLeft.value,
+ screenTop.value,
+ screenWidth.value,
+ screenHeight.value,
+ ];
+ },
+
+ /**
+ * This function takes in a left and top value and returns the screen they
+ * are located on.
+ * If the left and top are not on any screen, it will return the
+ * default screen
+ * @param left
+ * left or x coordinate
+ * @param top
+ * top or y coordinate
+ *
+ * @returns screen
+ * the screen the left and top are on otherwise, default screen
+ */
+ getWorkingScreen(left, top, width = 1, height = 1) {
+ // Get the screen manager
+ let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ // use screenForRect to get screen
+ // this returns the default screen if left and top are not
+ // on any screen
+ let screen = screenManager.screenForRect(left, top, width, height);
+
+ return screen;
+ },
+
+ /**
+ * Saves position and size of Picture-in-Picture window
+ * @param win The Picture-in-Picture window
+ */
+ savePosition(win) {
+ let xulStore = Services.xulStore;
+
+ let left = win.screenX;
+ let top = win.screenY;
+ let width = win.innerWidth;
+ let height = win.innerHeight;
+
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", left);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", top);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", width);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", height);
+ },
+
+ /**
+ * Load last Picture in Picture location and size
+ * @returns object
+ * The size and position of the last Picture in Picture window.
+ *
+ * top (int):
+ * The top position for the last player window.
+ * Otherwise NaN
+ *
+ * left (int):
+ * The left position for the last player window.
+ * Otherwise NaN
+ *
+ * width (int):
+ * The width of the player last window.
+ * Otherwise NaN
+ *
+ * height (int):
+ * The height of the player last window.
+ * Otherwise NaN
+ */
+ loadPosition() {
+ let xulStore = Services.xulStore;
+
+ let left = parseInt(
+ xulStore.getValue(PLAYER_URI, "picture-in-picture", "left")
+ );
+ let top = parseInt(
+ xulStore.getValue(PLAYER_URI, "picture-in-picture", "top")
+ );
+ let width = parseInt(
+ xulStore.getValue(PLAYER_URI, "picture-in-picture", "width")
+ );
+ let height = parseInt(
+ xulStore.getValue(PLAYER_URI, "picture-in-picture", "height")
+ );
+
+ return { top, left, width, height };
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ PictureInPicture,
+ "isMultiPipEnabled",
+ MULTI_PIP_ENABLED_PREF,
+ false
+);
diff --git a/toolkit/components/pictureinpicture/PictureInPictureControls.jsm b/toolkit/components/pictureinpicture/PictureInPictureControls.jsm
new file mode 100644
index 0000000000..e04e2e62f2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/PictureInPictureControls.jsm
@@ -0,0 +1,44 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 = [
+ "KEYBOARD_CONTROLS",
+ "TOGGLE_POLICIES",
+ "TOGGLE_POLICY_STRINGS",
+];
+
+// These denote which keyboard controls to show for a qualified video element.
+this.KEYBOARD_CONTROLS = {
+ NONE: 0,
+ PLAY_PAUSE: 1 << 0,
+ MUTE_UNMUTE: 1 << 1,
+ VOLUME: 1 << 2,
+ SEEK: 1 << 3,
+};
+
+// These are the possible toggle positions along the right side of
+// a qualified video element.
+this.TOGGLE_POLICIES = {
+ DEFAULT: 1,
+ HIDDEN: 2,
+ TOP: 3,
+ ONE_QUARTER: 4,
+ THREE_QUARTERS: 5,
+ BOTTOM: 6,
+};
+
+// These strings are used in the videocontrols.css stylesheet as
+// toggle policy attribute values for setting rules on the position
+// of the toggle.
+this.TOGGLE_POLICY_STRINGS = {
+ [TOGGLE_POLICIES.DEFAULT]: "default",
+ [TOGGLE_POLICIES.HIDDEN]: "hidden",
+ [TOGGLE_POLICIES.TOP]: "top",
+ [TOGGLE_POLICIES.ONE_QUARTER]: "one-quarter",
+ [TOGGLE_POLICIES.THREE_QUARTERS]: "three-quarters",
+ [TOGGLE_POLICIES.BOTTOM]: "bottom",
+};
diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js
new file mode 100644
index 0000000000..8aeb91bbfe
--- /dev/null
+++ b/toolkit/components/pictureinpicture/content/player.js
@@ -0,0 +1,669 @@
+/* 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/. */
+
+const { PictureInPicture } = ChromeUtils.import(
+ "resource://gre/modules/PictureInPicture.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { DeferredTask } = ChromeUtils.import(
+ "resource://gre/modules/DeferredTask.jsm"
+);
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const AUDIO_TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.audio-toggle.enabled";
+const KEYBOARD_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.keyboard-controls.enabled";
+
+// Time to fade the Picture-in-Picture video controls after first opening.
+const CONTROLS_FADE_TIMEOUT_MS = 3000;
+const RESIZE_DEBOUNCE_RATE_MS = 500;
+
+/**
+Quadrants!
+* 2 | 1
+* 3 | 4
+*/
+const TOP_RIGHT_QUADRANT = 1;
+const TOP_LEFT_QUADRANT = 2;
+const BOTTOM_LEFT_QUADRANT = 3;
+const BOTTOM_RIGHT_QUADRANT = 4;
+
+/**
+ * Public function to be called from PictureInPicture.jsm. This is the main
+ * entrypoint for initializing the player window.
+ *
+ * @param id (Number)
+ * A unique numeric ID for the window, used for Telemetry Events.
+ * @param wgp (WindowGlobalParent)
+ * The WindowGlobalParent that is hosting the originating video.
+ * @param videoRef {ContentDOMReference}
+ * A reference to the video element that a Picture-in-Picture window
+ * is being created for
+ */
+function setupPlayer(id, wgp, videoRef) {
+ Player.init(id, wgp, videoRef);
+}
+
+/**
+ * Public function to be called from PictureInPicture.jsm. This update the
+ * controls based on whether or not the video is playing.
+ *
+ * @param isPlaying (Boolean)
+ * True if the Picture-in-Picture video is playing.
+ */
+function setIsPlayingState(isPlaying) {
+ Player.isPlaying = isPlaying;
+}
+
+/**
+ * Public function to be called from PictureInPicture.jsm. This update the
+ * controls based on whether or not the video is muted.
+ *
+ * @param isMuted (Boolean)
+ * True if the Picture-in-Picture video is muted.
+ */
+function setIsMutedState(isMuted) {
+ Player.isMuted = isMuted;
+}
+
+/**
+ * The Player object handles initializing the player, holds state, and handles
+ * events for updating state.
+ */
+let Player = {
+ WINDOW_EVENTS: [
+ "click",
+ "contextmenu",
+ "dblclick",
+ "keydown",
+ "mouseup",
+ "MozDOMFullscreen:Entered",
+ "MozDOMFullscreen:Exited",
+ "resize",
+ "unload",
+ ],
+ actor: null,
+ /**
+ * Used for resizing Telemetry to avoid recording an event for every resize
+ * event. Instead, we wait until RESIZE_DEBOUNCE_RATE_MS has passed since the
+ * last resize event before recording.
+ */
+ resizeDebouncer: null,
+ /**
+ * Used for window movement Telemetry to determine if the player window has
+ * moved since the last time we checked.
+ */
+ lastScreenX: -1,
+ lastScreenY: -1,
+ id: -1,
+
+ /**
+ * When set to a non-null value, a timer is scheduled to hide the controls
+ * after CONTROLS_FADE_TIMEOUT_MS milliseconds.
+ */
+ showingTimeout: null,
+
+ /**
+ * Used to determine old window location when mouseup-ed for corner
+ * snapping drag vector calculation
+ */
+ oldMouseUpWindowX: window.screenX,
+ oldMouseUpWindowY: window.screenY,
+
+ /**
+ * Initializes the player browser, and sets up the initial state.
+ *
+ * @param id (Number)
+ * A unique numeric ID for the window, used for Telemetry Events.
+ * @param wgp (WindowGlobalParent)
+ * The WindowGlobalParent that is hosting the originating video.
+ * @param videoRef {ContentDOMReference}
+ * A reference to the video element that a Picture-in-Picture window
+ * is being created for
+ */
+ init(id, wgp, videoRef) {
+ this.id = id;
+
+ let holder = document.querySelector(".player-holder");
+ let browser = document.getElementById("browser");
+ browser.remove();
+
+ browser.setAttribute("nodefaultsrc", "true");
+
+ // Set the specific remoteType and browsingContextGroupID to use for the
+ // initial about:blank load. The combination of these two properties will
+ // ensure that the browser loads in the same process as our originating
+ // browser.
+ browser.setAttribute("remoteType", wgp.domProcess.remoteType);
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ wgp.browsingContext.group.id
+ );
+ holder.appendChild(browser);
+
+ this.actor = browser.browsingContext.currentWindowGlobal.getActor(
+ "PictureInPicture"
+ );
+ this.actor.sendAsyncMessage("PictureInPicture:SetupPlayer", {
+ videoRef,
+ });
+
+ PictureInPicture.weakPipToWin.set(this.actor, window);
+
+ for (let eventType of this.WINDOW_EVENTS) {
+ addEventListener(eventType, this);
+ }
+
+ // If the content process hosting the video crashes, let's
+ // just close the window for now.
+ browser.addEventListener("oop-browser-crashed", this);
+
+ this.revealControls(false);
+
+ if (Services.prefs.getBoolPref(AUDIO_TOGGLE_ENABLED_PREF, false)) {
+ const audioButton = document.getElementById("audio");
+ audioButton.hidden = false;
+ audioButton.previousElementSibling.hidden = false;
+ }
+
+ Services.telemetry.setEventRecordingEnabled("pictureinpicture", true);
+
+ this.resizeDebouncer = new DeferredTask(() => {
+ this.recordEvent("resize", {
+ width: window.outerWidth.toString(),
+ height: window.outerHeight.toString(),
+ });
+ }, RESIZE_DEBOUNCE_RATE_MS);
+
+ this.lastScreenX = window.screenX;
+ this.lastScreenY = window.screenY;
+
+ this.recordEvent("create", {
+ width: window.outerWidth.toString(),
+ height: window.outerHeight.toString(),
+ screenX: window.screenX.toString(),
+ screenY: window.screenY.toString(),
+ });
+
+ this.computeAndSetMinimumSize(window.outerWidth, window.outerHeight);
+
+ // alwaysontop windows are not focused by default, so we have to do it
+ // ourselves. We use requestAnimationFrame since we have to wait until the
+ // window is visible before it can focus.
+ window.requestAnimationFrame(() => {
+ window.focus();
+ });
+ },
+
+ uninit() {
+ this.resizeDebouncer.disarm();
+ PictureInPicture.unload(window, this.actor);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click": {
+ this.onClick(event);
+ this.controls.removeAttribute("keying");
+ break;
+ }
+
+ case "contextmenu": {
+ event.preventDefault();
+ break;
+ }
+
+ case "dblclick": {
+ this.onDblClick(event);
+ break;
+ }
+
+ case "keydown": {
+ if (event.keyCode == KeyEvent.DOM_VK_TAB) {
+ this.controls.setAttribute("keying", true);
+ } else if (
+ event.keyCode == KeyEvent.DOM_VK_ESCAPE &&
+ this.controls.hasAttribute("keying")
+ ) {
+ this.controls.removeAttribute("keying");
+
+ // We preventDefault to avoid exiting fullscreen if we happen
+ // to be in it.
+ event.preventDefault();
+ } else if (
+ Services.prefs.getBoolPref(KEYBOARD_CONTROLS_ENABLED_PREF, false) &&
+ (event.keyCode != KeyEvent.DOM_VK_SPACE || !event.target.id)
+ ) {
+ // Pressing "space" fires a "keydown" event which can also trigger a control
+ // button's "click" event. Handle the "keydown" event only when the event did
+ // not originate from a control button and it is not a "space" keypress.
+ this.onKeyDown(event);
+ }
+
+ break;
+ }
+
+ case "mouseup": {
+ this.onMouseUp(event);
+ break;
+ }
+
+ // Normally, the DOMFullscreenParent / DOMFullscreenChild actors
+ // would take care of firing the `fullscreen-painted` notification,
+ // however, those actors are only ever instantiated when a <browser>
+ // is fullscreened, and not a <body> element in a parent-process
+ // chrome privileged DOM window.
+ //
+ // Rather than trying to re-engineer JSWindowActors to be re-usable for
+ // this edge-case, we do the work of firing fullscreen-painted when
+ // transitioning in and out of fullscreen ourselves here.
+ case "MozDOMFullscreen:Entered":
+ // Intentional fall-through
+ case "MozDOMFullscreen:Exited": {
+ let { lastTransactionId } = window.windowUtils;
+ window.addEventListener("MozAfterPaint", function onPainted(event) {
+ if (event.transactionId > lastTransactionId) {
+ window.removeEventListener("MozAfterPaint", onPainted);
+ Services.obs.notifyObservers(window, "fullscreen-painted");
+ }
+ });
+ break;
+ }
+
+ case "oop-browser-crashed": {
+ this.closePipWindow({ reason: "browser-crash" });
+ break;
+ }
+
+ case "resize": {
+ this.onResize(event);
+ break;
+ }
+
+ case "unload": {
+ this.uninit();
+ break;
+ }
+ }
+ },
+
+ closePipWindow(closeData) {
+ const { reason } = closeData;
+
+ if (PictureInPicture.isMultiPipEnabled) {
+ PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor });
+ } else {
+ PictureInPicture.closeAllPipWindows({ reason });
+ }
+ },
+
+ onDblClick(event) {
+ if (event.target.id == "controls") {
+ if (document.fullscreenElement == document.body) {
+ document.exitFullscreen();
+ } else {
+ document.body.requestFullscreen();
+ }
+ event.preventDefault();
+ }
+ },
+
+ onClick(event) {
+ switch (event.target.id) {
+ case "audio": {
+ if (this.isMuted) {
+ this.actor.sendAsyncMessage("PictureInPicture:Unmute");
+ } else {
+ this.actor.sendAsyncMessage("PictureInPicture:Mute");
+ }
+ break;
+ }
+
+ case "close": {
+ this.actor.sendAsyncMessage("PictureInPicture:Pause", {
+ reason: "pip-closed",
+ });
+ this.closePipWindow({ reason: "close-button" });
+ break;
+ }
+
+ case "playpause": {
+ if (!this.isPlaying) {
+ this.actor.sendAsyncMessage("PictureInPicture:Play");
+ this.revealControls(false);
+ } else {
+ this.actor.sendAsyncMessage("PictureInPicture:Pause");
+ this.revealControls(true);
+ }
+
+ break;
+ }
+
+ case "unpip": {
+ PictureInPicture.focusTabAndClosePip(window, this.actor);
+ break;
+ }
+ }
+ },
+
+ onKeyDown(event) {
+ this.actor.sendAsyncMessage("PictureInPicture:KeyDown", {
+ altKey: event.altKey,
+ shiftKey: event.shiftKey,
+ metaKey: event.metaKey,
+ ctrlKey: event.ctrlKey,
+ keyCode: event.keyCode,
+ });
+ },
+
+ /**
+ * PiP Corner Snapping Helper Function
+ * Determines the quadrant the PiP window is currently in.
+ */
+ determineCurrentQuadrant() {
+ // Determine center coordinates of window.
+ let windowCenterX = window.screenX + window.innerWidth / 2;
+ let windowCenterY = window.screenY + window.innerHeight / 2;
+ let quadrant = null;
+ let halfWidth = window.screen.availLeft + window.screen.availWidth / 2;
+ let halfHeight = window.screen.availTop + window.screen.availHeight / 2;
+
+ let leftHalf = windowCenterX < halfWidth;
+ let rightHalf = windowCenterX > halfWidth;
+ let topHalf = windowCenterY < halfHeight;
+ let bottomHalf = windowCenterY > halfHeight;
+
+ if (leftHalf && topHalf) {
+ quadrant = TOP_LEFT_QUADRANT;
+ } else if (rightHalf && topHalf) {
+ quadrant = TOP_RIGHT_QUADRANT;
+ } else if (leftHalf && bottomHalf) {
+ quadrant = BOTTOM_LEFT_QUADRANT;
+ } else if (rightHalf && bottomHalf) {
+ quadrant = BOTTOM_RIGHT_QUADRANT;
+ }
+ return quadrant;
+ },
+
+ /**
+ * Helper function to actually move/snap the PiP window.
+ * Moves the PiP window to the top right.
+ */
+ moveToTopRight() {
+ window.moveTo(
+ window.screen.availLeft + window.screen.availWidth - window.innerWidth,
+ window.screen.availTop
+ );
+ },
+
+ /**
+ * Moves the PiP window to the top left.
+ */
+ moveToTopLeft() {
+ window.moveTo(window.screen.availLeft, window.screen.availTop);
+ },
+
+ /**
+ * Moves the PiP window to the bottom right.
+ */
+ moveToBottomRight() {
+ window.moveTo(
+ window.screen.availLeft + window.screen.availWidth - window.innerWidth,
+ window.screen.availTop + window.screen.availHeight - window.innerHeight
+ );
+ },
+
+ /**
+ * Moves the PiP window to the bottom left.
+ */
+ moveToBottomLeft() {
+ window.moveTo(
+ window.screen.availLeft,
+ window.screen.availTop + window.screen.availHeight - window.innerHeight
+ );
+ },
+
+ determineDirectionDragged() {
+ // Determine change in window location.
+ let deltaX = this.oldMouseUpWindowX - window.screenX;
+ let deltaY = this.oldMouseUpWindowY - window.screenY;
+ let dragDirection = "";
+
+ if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
+ dragDirection = "draggedRight";
+ } else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) {
+ dragDirection = "draggedLeft";
+ } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY < 0) {
+ dragDirection = "draggedDown";
+ } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY > 0) {
+ dragDirection = "draggedUp";
+ }
+ return dragDirection;
+ },
+
+ onMouseUp(event) {
+ if (
+ window.screenX != this.lastScreenX ||
+ window.screenY != this.lastScreenY
+ ) {
+ this.recordEvent("move", {
+ screenX: window.screenX.toString(),
+ screenY: window.screenY.toString(),
+ });
+ }
+ this.lastScreenX = window.screenX;
+ this.lastScreenY = window.screenY;
+
+ // Corner snapping changes start here.
+ // Check if metakey pressed and macOS
+ let quadrant = this.determineCurrentQuadrant();
+ let dragAction = this.determineDirectionDragged();
+
+ if (event.metaKey && AppConstants.platform == "macosx" && dragAction) {
+ // Moving logic based on current quadrant and direction of drag.
+ switch (quadrant) {
+ case TOP_RIGHT_QUADRANT:
+ switch (dragAction) {
+ case "draggedRight":
+ this.moveToTopRight();
+ break;
+ case "draggedLeft":
+ this.moveToTopLeft();
+ break;
+ case "draggedDown":
+ this.moveToBottomRight();
+ break;
+ case "draggedUp":
+ this.moveToTopRight();
+ break;
+ }
+ break;
+ case TOP_LEFT_QUADRANT:
+ switch (dragAction) {
+ case "draggedRight":
+ this.moveToTopRight();
+ break;
+ case "draggedLeft":
+ this.moveToTopLeft();
+ break;
+ case "draggedDown":
+ this.moveToBottomLeft();
+ break;
+ case "draggedUp":
+ this.moveToTopLeft();
+ break;
+ }
+ break;
+ case BOTTOM_LEFT_QUADRANT:
+ switch (dragAction) {
+ case "draggedRight":
+ this.moveToBottomRight();
+ break;
+ case "draggedLeft":
+ this.moveToBottomLeft();
+ break;
+ case "draggedDown":
+ this.moveToBottomLeft();
+ break;
+ case "draggedUp":
+ this.moveToTopLeft();
+ break;
+ }
+ break;
+ case BOTTOM_RIGHT_QUADRANT:
+ switch (dragAction) {
+ case "draggedRight":
+ this.moveToBottomRight();
+ break;
+ case "draggedLeft":
+ this.moveToBottomLeft();
+ break;
+ case "draggedDown":
+ this.moveToBottomRight();
+ break;
+ case "draggedUp":
+ this.moveToTopRight();
+ break;
+ }
+ break;
+ } // Switch close.
+ } // Metakey close.
+ this.oldMouseUpWindowX = window.screenX;
+ this.oldMouseUpWindowY = window.screenY;
+ },
+
+ onResize(event) {
+ this.resizeDebouncer.disarm();
+ this.resizeDebouncer.arm();
+ },
+
+ onCommand(event) {
+ this.closePipWindow({ reason: "player-shortcut" });
+ },
+
+ get controls() {
+ delete this.controls;
+ return (this.controls = document.getElementById("controls"));
+ },
+
+ _isPlaying: false,
+ /**
+ * isPlaying returns true if the video is currently playing.
+ *
+ * @return Boolean
+ */
+ get isPlaying() {
+ return this._isPlaying;
+ },
+
+ /**
+ * Set isPlaying to true if the video is playing, false otherwise. This will
+ * update the internal state and displayed controls.
+ */
+ set isPlaying(isPlaying) {
+ this._isPlaying = isPlaying;
+ this.controls.classList.toggle("playing", isPlaying);
+ const playButton = document.getElementById("playpause");
+ let strId = "pictureinpicture-" + (isPlaying ? "pause" : "play");
+ document.l10n.setAttributes(playButton, strId);
+ },
+
+ _isMuted: false,
+ /**
+ * isMuted returns true if the video is currently muted.
+ *
+ * @return Boolean
+ */
+ get isMuted() {
+ return this._isMuted;
+ },
+
+ /**
+ * Set isMuted to true if the video is muted, false otherwise. This will
+ * update the internal state and displayed controls.
+ */
+ set isMuted(isMuted) {
+ this._isMuted = isMuted;
+ this.controls.classList.toggle("muted", isMuted);
+ const audioButton = document.getElementById("audio");
+ let strId = "pictureinpicture-" + (isMuted ? "unmute" : "mute");
+ document.l10n.setAttributes(audioButton, strId);
+ },
+
+ recordEvent(type, args) {
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ type,
+ "player",
+ this.id,
+ args
+ );
+ },
+
+ /**
+ * Makes the player controls visible.
+ *
+ * @param revealIndefinitely (Boolean)
+ * If false, this will hide the controls again after
+ * CONTROLS_FADE_TIMEOUT_MS milliseconds has passed. If true, the controls
+ * will remain visible until revealControls is called again with
+ * revealIndefinitely set to false.
+ */
+ revealControls(revealIndefinitely) {
+ clearTimeout(this.showingTimeout);
+ this.showingTimeout = null;
+
+ this.controls.setAttribute("showing", true);
+ if (!revealIndefinitely) {
+ this.showingTimeout = setTimeout(() => {
+ this.controls.removeAttribute("showing");
+ }, CONTROLS_FADE_TIMEOUT_MS);
+ }
+ },
+
+ /**
+ * Given a width and height for a video, computes the minimum dimensions for
+ * the player window, and then sets them on the root element.
+ *
+ * This is currently only used on Linux GTK, where the OS doesn't already
+ * impose a minimum window size. For other platforms, this function is a
+ * no-op.
+ *
+ * @param width (Number)
+ * The width of the video being played.
+ * @param height (Number)
+ * The height of the video being played.
+ */
+ computeAndSetMinimumSize(width, height) {
+ if (!AppConstants.MOZ_WIDGET_GTK) {
+ return;
+ }
+
+ // Using inspection, these seem to be the right minimums for each dimension
+ // so that the controls don't get too crowded.
+ const MIN_WIDTH = 120;
+ const MIN_HEIGHT = 80;
+
+ let resultWidth = width;
+ let resultHeight = height;
+ let aspectRatio = width / height;
+
+ // Take the smaller of the two dimensions, and set it to the minimum.
+ // Then calculate the other dimension using the aspect ratio to get
+ // both minimums.
+ if (width < height) {
+ resultWidth = MIN_WIDTH;
+ resultHeight = Math.round(MIN_WIDTH / aspectRatio);
+ } else {
+ resultHeight = MIN_HEIGHT;
+ resultWidth = Math.round(MIN_HEIGHT * aspectRatio);
+ }
+
+ document.documentElement.style.minWidth = resultWidth + "px";
+ document.documentElement.style.minHeight = resultHeight + "px";
+ },
+};
diff --git a/toolkit/components/pictureinpicture/content/player.xhtml b/toolkit/components/pictureinpicture/content/player.xhtml
new file mode 100644
index 0000000000..c0b13a414e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/content/player.xhtml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html>
+<!-- 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/. -->
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ windowtype="Toolkit:PictureInPicture"
+ chromemargin="0,0,0,0">
+ <head>
+ <meta charset="utf-8"/>
+ <link rel="stylesheet" type="text/css"
+ href="chrome://global/skin/pictureinpicture/player.css"/>
+ <link rel="localization" href="toolkit/pictureinpicture/pictureinpicture.ftl"/>
+ <link rel="localization" href="browser/browserSets.ftl"/>
+ <script src="chrome://global/content/pictureinpicture/player.js"></script>
+ <title data-l10n-id="pictureinpicture-player-title"></title>
+ </head>
+
+ <body>
+ <xul:commandset>
+ <xul:command id="View:PictureInPicture" oncommand="Player.onCommand(event);"/>
+ </xul:commandset>
+
+ <xul:keyset>
+#ifndef XP_MACOSX
+ <xul:key data-l10n-id="picture-in-picture-toggle-shortcut" command="View:PictureInPicture" modifiers="accel,shift"/>
+ <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-alt" command="View:PictureInPicture" modifiers="accel,shift"/>
+#else
+ <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-mac" command="View:PictureInPicture" modifiers="accel,alt,shift"/>
+ <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-mac-alt" command="View:PictureInPicture" modifiers="accel,alt,shift"/>
+#endif
+ </xul:keyset>
+
+ <div class="player-holder">
+ <xul:browser type="content" primary="true" remote="true" remoteType="web" id="browser" tabindex="-1"></xul:browser>
+ </div>
+ <div id="controls">
+ <button id="close"
+ class="control-item"
+#ifdef XP_MACOSX
+ mac="true"
+#endif
+ data-l10n-id="pictureinpicture-close"
+ tabindex="3"/>
+ <div id="controls-bottom">
+ <button id="unpip" class="control-item" data-l10n-id="pictureinpicture-unpip" tabindex="2"></button>
+ <div class="gap"></div>
+ <button id="playpause" class="control-item" tabindex="1" autofocus="true"
+ data-l10n-id="pictureinpicture-pause"/>
+ <div class="gap" hidden="true"></div>
+ <button id="audio" class="control-item" hidden="true" tabindex="1"
+ data-l10n-id="pictureinpicture-mute"/>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/toolkit/components/pictureinpicture/docs/PiP-diagram.svg b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg
new file mode 100644
index 0000000000..ffff499660
--- /dev/null
+++ b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" xmlns:xlink="http://www.w3.org/1999/xlink" width="1241" height="1169" viewBox="-0.5 -0.5 1241 1169"><path fill="#ffe6cc" stroke="#d79b00" pointer-events="all" d="M0 860h390v280H0z"/><path d="M290 1070v20h761l-.01 58.86" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M1050.99 1155.61l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><path fill="none" stroke="#000" pointer-events="all" d="M80 960h210v110H80z"/><path d="M0 0h480v340H0z" fill="#ffe6cc" stroke="#d79b00" stroke-miterlimit="10" pointer-events="all"/><circle cx="415" cy="15" fill="transparent" stroke="#d79b00" pointer-events="all" r="10"/><circle cx="440" cy="15" fill="transparent" stroke="#d79b00" pointer-events="all" r="10"/><circle cx="465" cy="15" fill="transparent" stroke="#008cff" pointer-events="all" r="10"/><path d="M0 40h30V15c0-2.76 2.24-5 5-5h135c2.76 0 5 2.24 5 5v25h305M0 110h480M100 60c0-2.76 2.24-5 5-5h360c2.76 0 5 2.24 5 5v25c0 2.76-2.24 5-5 5H105c-2.76 0-5-2.24-5-5z" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M37 17h11l4 4v14H37z" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M48 17v4l4 1" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M107 64h11l4 4v14h-15z" fill="none" stroke="#c4c4c4" stroke-width="2" stroke-miterlimit="10" pointer-events="all"/><path d="M118 64v4l4 1" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M12 74l10-10v6h10v8H22v6zM62 74L52 64v6H42v8h10v6zM87.6 77.3a6 6 0 01-8.22 2.06c-2.84-1.69-3.77-5.36-2.09-8.21 1.69-2.84 5.36-3.79 8.21-2.11l-1.6 1.46 7.9 1.8-1.8-7.5-1.7 1.6a9.816 9.816 0 00-10.91-.53 9.799 9.799 0 00-4.62 9.89c.6 3.93 3.53 7.1 7.39 8.03 3.87.93 7.91-.57 10.24-3.79z" fill="#c4c4c4" stroke="#c4c4c4" stroke-width="2" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:25px;margin-left:62px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:17px;font-family:Helvetica;color:#666;line-height:1.2;pointer-events:all;white-space:nowrap"><div>Twitch</div></div></div></div></foreignObject><text x="62" y="30" fill="#666" font-family="Helvetica" font-size="17">Twitch</text></switch><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:73px;margin-left:132px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:17px;font-family:Helvetica;color:#666;line-height:1.2;pointer-events:all;white-space:nowrap"><div>https://www.twitch.tv</div></div></div></div></foreignObject><text x="132" y="78" fill="#666" font-family="Helvetica" font-size="17">https://www.twitch.tv</text></switch><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M0 110h480v230H0z"/><path d="M78.08 315c-3.08 0-5.58-2.3-5.58-5.14V140.14c0-2.84 2.5-5.14 5.58-5.14h323.84c3.08 0 5.58 2.3 5.58 5.14v169.72c0 2.84-2.5 5.14-5.58 5.14z" fill="#f66" pointer-events="all"/><path d="M221.65 267.98l44.66-23.6-44.66-23.44zm-39.3 24.5c-4.07 0-7.3-3.53-7.3-6.75v-82.85c0-3.8 3.8-6.79 7.29-6.79h114.89c5.16 0 7.72 3.99 7.72 6.76v82.78c0 3.57-3.4 6.85-7.44 6.85zM75.29 176.14v133.72c0 1.42 1.25 2.57 2.79 2.57h323.84c1.54 0 2.79-1.15 2.79-2.57V176.14z" fill-opacity=".5" fill="#fff" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe center;width:1px;height:1px;padding-top:136px;margin-left:240px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#fff;line-height:1.2;pointer-events:all;white-space:nowrap">Video</div></div></div></foreignObject><text x="240" y="148" fill="#FFF" font-family="Helvetica" font-size="12" text-anchor="middle">Video</text></switch><path fill="#08f" stroke="#000" pointer-events="all" d="M387.5 225h20v20h-20z"/><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M0 420h710v380H0z"/><path fill="none" pointer-events="all" d="M0 420h200v30H0z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:423px;margin-left:2px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>Content process hosting the video</div><div><br/></div></div></div></div></foreignObject><text x="2" y="435" font-family="Helvetica" font-size="12">Content process hosting the video</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M230 450h180v80H230z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:490px;margin-left:231px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">videocontrols.js</div></div></div></foreignObject><text x="320" y="494" font-family="Helvetica" font-size="12" text-anchor="middle">videocontrols.js</text></switch><path d="M407.5 235h10q10 0 10 10v92.5q0 10-10 10H220q-10 0-10 10V480q0 10 10 10h10M175 630v69.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M175 706.65l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:670px;margin-left:175px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">MozTogglePictureInPicture chrome event</div></div></div></foreignObject><text x="175" y="673" font-family="Helvetica" font-size="11" text-anchor="middle">MozTogglePictureInPicture chrome event</text></switch><path d="M50 585H30v245h195v119.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M225 956.65l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:831px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">JSWindowActor messaging</div></div></div></foreignObject><text x="91" y="834" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor messaging</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M50 540h250v90H50z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:585px;margin-left:51px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureToggleChild</div></div></div></foreignObject><text x="175" y="589" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureToggleChild</text></switch><path d="M300 750h20v290h-39.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M273.35 1040l9-4.5-2.25 4.5 2.25 4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:833px;margin-left:319px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">JSWindowActor messaging</div></div></div></foreignObject><text x="319" y="836" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor messaging</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M50 710h250v80H50z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:750px;margin-left:51px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal"><div>PictureInPictureLauncherChild<br/></div></div></div></div></foreignObject><text x="175" y="754" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureLauncherChild</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M430 680h250v80H430z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:720px;margin-left:431px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureChild for player &lt;video&gt;</div></div></div></foreignObject><text x="555" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureChild for player &lt;video&gt;</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M90 960h180v40H90z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:980px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureToggleParent</div></div></div></foreignObject><text x="180" y="984" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureToggleParent</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M90 1020h180v40H90z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:1040px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureLauncherParent</div></div></div></foreignObject><text x="180" y="1044" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureLauncherParent</text></switch><path fill="none" pointer-events="all" d="M80 930h120v30H80z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:933px;margin-left:82px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>PictureInPicture.jsm</div><div><br/></div></div></div></div></foreignObject><text x="82" y="945" font-family="Helvetica" font-size="12">PictureInPicture.jsm</text></switch><path fill="#ffe6cc" stroke="#d79b00" pointer-events="all" d="M830 640h410v520H830z"/><path fill="none" pointer-events="all" d="M0 860h100v20H0z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:863px;margin-left:2px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap">Parent process</div></div></div></foreignObject><text x="2" y="875" font-family="Helvetica" font-size="12">Parent process</text></switch><path fill="none" pointer-events="all" d="M840 650h250v20H840z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:653px;margin-left:842px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap">player.xhtml / player.js - alwaysontop window</div></div></div></foreignObject><text x="842" y="665" font-family="Helvetica" font-size="12">player.xhtml / player.js - alwaysontop wi...</text></switch><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M840 920h390v230H840z"/><path fill="none" pointer-events="all" d="M850 920h340v30H850z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:923px;margin-left:852px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>remote &lt;xul:browser&gt; running in the same content process as</div><div>original video</div></div></div></div></foreignObject><text x="852" y="935" font-family="Helvetica" font-size="12">remote &lt;xul:browser&gt; running in the same content process...</text></switch><path d="M865.58 1125c-1.48 0-2.9-.47-3.94-1.3-1.05-.83-1.64-1.95-1.64-3.13V974.43c0-1.18.59-2.3 1.64-3.13 1.04-.83 2.46-1.3 3.94-1.3h323.84c1.48 0 2.9.47 3.94 1.3 1.05.83 1.64 1.95 1.64 3.13v146.14c0 1.18-.59 2.3-1.64 3.13-1.04.83-2.46 1.3-3.94 1.3z" fill="#f66" pointer-events="all"/><path d="M1009.15 1084.51l44.66-20.32-44.66-20.19zm-39.3 21.1c-4.07 0-7.3-3.04-7.3-5.82v-71.34c0-3.27 3.8-5.84 7.29-5.84h114.89c5.16 0 7.72 3.43 7.72 5.82v71.28c0 3.08-3.4 5.9-7.44 5.9zm-107.06-100.18v115.14c0 .59.3 1.15.82 1.57.52.41 1.23.65 1.97.65h323.84c.74 0 1.45-.24 1.97-.65.52-.42.82-.98.82-1.57v-115.14z" fill-opacity=".5" fill="#fff" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe center;width:1px;height:1px;padding-top:971px;margin-left:1028px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#fff;line-height:1.2;pointer-events:all;white-space:nowrap">Player video</div></div></div></foreignObject><text x="1028" y="983" fill="#FFF" font-family="Helvetica" font-size="12" text-anchor="middle">Player video</text></switch><path d="M915 720H690.1" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M683.35 720l9-4.5-2.25 4.5 2.25 4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:721px;margin-left:771px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"><div>JSWindowActor</div><div>Messaging<br/></div></div></div></div></foreignObject><text x="771" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor...</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M915 700h180v40H915z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:720px;margin-left:916px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureParent</div></div></div></foreignObject><text x="1005" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureParent</text></switch><path d="M555 760h215v287.5h79.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M856.65 1047.5l-9 4.5 2.25-4.5-2.25-4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><path d="M185 1070h0" fill="none" stroke="#000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M185 1070h0z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/><path fill="none" pointer-events="all" d="M570 1070h40v20h-40z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:38px;height:1px;padding-top:1080px;margin-left:571px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">Services.ww.openWindow</div></div></div></foreignObject><text x="590" y="1084" font-family="Helvetica" font-size="12" text-anchor="middle">Servic...</text></switch></svg> \ No newline at end of file
diff --git a/toolkit/components/pictureinpicture/docs/index.rst b/toolkit/components/pictureinpicture/docs/index.rst
new file mode 100644
index 0000000000..81984c2b94
--- /dev/null
+++ b/toolkit/components/pictureinpicture/docs/index.rst
@@ -0,0 +1,190 @@
+.. _components/pictureinpicture:
+
+==================
+Picture-in-Picture
+==================
+
+This component makes it possible for a ``<video>`` element on a web page to be played within
+an always-on-top video player.
+
+This documentation covers the architecture and inner workings of both the mechanism that
+displays the ``<video>`` in the always-on-top video player, as well as the mechanism that
+displays the Picture-in-Picture toggle that overlays ``<video>`` elements, which is the primary
+method for launching the feature.
+
+
+High-level overview
+===================
+
+The following diagram tries to illustrate the subcomponents, and how they interact with one another.
+
+.. image:: PiP-diagram.svg
+
+Let's suppose that the user has loaded a document with a ``<video>`` in it, and they decide to open
+it in a Picture-in-Picture window. What happens?
+
+First the ``PictureInPictureToggleChild`` component notices when ``<video>`` elements are added to the
+DOM, and monitors the mouse as it moves around the document. Once the mouse intersects a ``<video>``,
+``PictureInPictureToggleChild`` causes the Picture-in-Picture toggle to appear on that element.
+
+If the user clicks on that toggle, then the ``PictureInPictureToggleChild`` dispatches a chrome-only
+``MozTogglePictureInPicture`` event on the video, which is handled by the ``PictureInPictureLauncherChild`` actor
+for that document. The reason for the indirection via the event is that the media context menu can also
+trigger Picture-in-Picture by dispatching the same event on the video. Upon handling the event, the
+``PictureInPictureLauncherChild`` actor then sends a ``PictureInPicture:Request`` message to the parent process.
+The parent process opens up the always-on-top player window, with a remote ``<xul:browser>`` that runs in
+the same content process as the original ``<video>``. The parent then sends a message to the player
+window's remote ``<xul:browser>`` loaded in the player window. A ``PictureInPictureChild`` actor
+is instantiated for the empty document loaded inside of the player window browser. This
+``PictureInPictureChild`` actor constructs its own ``<video>`` element, and then tells Gecko to clone the
+frames from the original ``<video>`` to the newly created ``<video>``.
+
+At this point, the video is displaying in the Picture-in-Picture player window.
+
+Next, we'll discuss the individual subcomponents, and how they operate at a more detailed level.
+
+
+The Picture-in-Picture toggle
+=============================
+
+One of the primary challenges faced when developing this feature was the fact that, in practice, mouse
+events tend not to reach ``<video>`` elements. This is usually because the ``<video>`` element is
+contained within a hierarchy of other DOM elements that are capturing and handling any events that
+come down. This often occurs on sites that construct their own video controls. This is why we cannot
+simply use a ``mouseover`` event handler on the ``<video>`` UAWidget - on sites that do the event
+capturing, we'll never receive those events and the toggle will not be accessible.
+
+Other times, the problem is that the video is overlaid with a semi or fully transparent element
+which captures any mouse events that would normally be dispatched to the underlying ``<video>``.
+This can occur, for example, on sites that want to display an overlay when the video is paused.
+
+To work around this problem, the `PictureInPictureToggleChild` actor class samples the latest
+``mousemove`` event every ``MOUSEMOVE_PROCESSING_DELAY_MS`` milliseconds, and then calls
+``nsIDOMWindowUtils.nodesFromRect`` with the ``aOnlyVisible`` argument to get the full
+list of visible nodes that exist underneath a 1x1 rect positioned at the mouse cursor.
+
+If a ``<video>`` is in that list, then we reach into its shadow root, and update some
+attributes to tell it to maybe show the toggle.
+
+The underlying ``UAWidget`` for the video is defined in ``videocontrols.js``, and ultimately
+chooses whether or not to display the toggle based on the following heuristics:
+
+1. Is the video less than 45 seconds?
+2. Is either the width or the height of the video less than 160px?
+3. Is the video silent?
+
+If any of the above is true, the underlying ``UAWidget`` will hide the toggle, since it's
+unlikely that the user will want to pop the video out into an always-on-top player window.
+
+
+Video registration
+==================
+
+Sampling the latest ``mousemove`` event every ``MOUSEMOVE_PROCESSING_DELAY_MS`` is not free,
+computationally speaking, so we only do this if there are one or more ``<video>`` elements
+visible on the page. We use an ``IntersectionObserver`` to notice when there is a ``<video>``
+within the viewport, and if there are 1 or more ``<video>`` elements visible, then we start
+sampling the ``mousemove`` event.
+
+Videos are added to the ``IntersectionObserver`` when they are added to the DOM by listening
+for the ``UAWidgetSetupOrChange`` event. This is considered being "registered".
+
+
+``docState``
+============
+
+``PictureInPictureChild.jsm`` contains a ``WeakMap`` mapping ``document``'s to various information
+that ``PictureInPictureToggleChild`` wants to retain for the lifetime of that ``document``. For
+example, whether or not we're in the midst of handling the user clicking down on their pointer
+device. Any state that needs to be remembered should be added to the ``docState`` ``WeakMap``.
+
+
+Clicking on the toggle
+======================
+
+If the user clicks on the Picture-in-Picture toggle, we don't want the underlying webpage to
+know that this happened, since this could result in unexpected behaviour, like a page
+navigation (for example, if the ``<video>`` is a long-running advertisement that navigates
+upon click).
+
+To accomplish this, we listen for all events fired on a mouse click on the root window during
+the capturing phase. This allows us to handle the events before they are dispatched to content.
+
+The first event that is fired, ``pointerdown``, is captured, and we check the ``docState`` to see
+whether or not we're showing a toggle on any videos. If so, we check the coordinates of that
+toggle against the coordinates of the ``pointerdown`` event to determine if the user is clicking
+on the toggle. If so, we set a flag in the ``docState`` so that any subsequent events from the
+click (like ``mousedown``, ``mouseup``, ``pointerup``, ``click``) are captured and suppressed.
+If the ``pointerdown`` event didn't occur within a toggle, we let the events pass through as
+normal.
+
+If we determine that the click has occurred on the toggle, a ``MozTogglePictureInPicture`` event
+is dispatched on the underlying ``<video>``. This event is handled by the separate
+``PictureInPictureLauncherChild`` class.
+
+PictureInPictureLauncherChild
+=============================
+
+A small actor class whose only responsibility is to tell the parent process to open an always-on-top-window by sending a ``PictureInPicture:Request`` message to its parent actor.
+
+Currently, this only occurs when a chrome-only ``MozTogglePictureInPicture`` event is dispatched by the ``PictureInPictureToggleChild`` when the user clicks the Picture-in-Picture toggle button
+or uses the context-menu.
+
+PictureInPictureChild
+=====================
+
+The ``PictureInPictureChild`` actor class will run in a content process containing a video, and is instantiated when the player window's `player.js` script runs its initialization. A ``PictureInPictureChild`` maps an individual ``<video>``
+to a player window instance. It creates an always-on-top window, and sets up a new ``<video>`` inside of this window to clone frames from another ``<video>``
+(which will be in the same process, and have its own ``PictureInPictureChild``). Creating this window also causes the new ``PictureInPictureChild`` to be created.
+This instance will monitor the originating ``<video>`` for changes, and to receive commands from the player window if the user wants to control the ``<video>``.
+
+PictureInPicture.jsm
+====================
+
+This module runs in the parent process, and is also the scope where all ``PictureInPictureParent`` instances reside. ``PictureInPicture.jsm``'s job is to send and receive messages from ``PictureInPictureChild`` instances, and to react appropriately.
+
+Critically, ``PictureInPicture.jsm`` is responsible for opening up the always-on-top player window, and passing the relevant information about the ``<video>`` to be displayed to it.
+
+
+The Picture-in-Picture player window
+====================================
+
+The Picture-in-Picture player window is a chrome-privileged window that loads an XHTML document. That document contains a remote ``<browser>`` element which is repurposed during window initialization to load in the same content process as the originating ``<video>``.
+
+The player window is where the player controls are defined, like "Play" and "Pause". When the user interacts with the player controls, a message is sent down to the appropriate ``PictureInPictureChild`` to call the appropriate method on the underlying ``<video>`` element in the originating tab.
+
+
+Cloning the video frames
+========================
+
+While it appears as if the video is moving from the original ``<video>`` element to the player window, what's actually occurring is that the video frames are being *cloned* to the player window ``<video>`` element. This cloning is done at the platform level using a privileged method on the ``<video>`` element: ``cloneElementVisually``.
+
+
+``cloneElementVisually``
+------------------------
+
+.. code-block:: js
+
+ Promise<void> video.cloneElementVisually(otherVideo);
+
+This will clone the frames being decoded for ``video`` and display them on the ``otherVideo`` element as well. The returned Promise resolves once the cloning has successfully started.
+
+
+``stopCloningElementVisually``
+------------------------------
+
+.. code-block:: js
+
+ void video.stopCloningElementVisually();
+
+If ``video`` is being cloned visually to another element, calling this method will stop the cloning.
+
+
+``isCloningElementVisually``
+----------------------------
+
+.. code-block:: js
+
+ boolean video.isCloningElementVisually;
+
+A read-only value that returns ``true`` if ``video`` is being cloned visually.
diff --git a/toolkit/components/pictureinpicture/jar.mn b/toolkit/components/pictureinpicture/jar.mn
new file mode 100644
index 0000000000..19d9717138
--- /dev/null
+++ b/toolkit/components/pictureinpicture/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# 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/.
+
+toolkit.jar:
+* content/global/pictureinpicture/player.xhtml (content/player.xhtml)
+ content/global/pictureinpicture/player.js (content/player.js)
diff --git a/toolkit/components/pictureinpicture/moz.build b/toolkit/components/pictureinpicture/moz.build
new file mode 100644
index 0000000000..ebdf399e10
--- /dev/null
+++ b/toolkit/components/pictureinpicture/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Video/Audio Controls")
+
+SPHINX_TREES["pictureinpicture"] = "docs"
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "PictureInPicture.jsm",
+ "PictureInPictureControls.jsm",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "tests/browser.ini",
+]
diff --git a/toolkit/components/pictureinpicture/tests/.eslintrc.js b/toolkit/components/pictureinpicture/tests/.eslintrc.js
new file mode 100644
index 0000000000..1779fd7f1c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/.eslintrc.js
@@ -0,0 +1,5 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/browser-test"],
+};
diff --git a/toolkit/components/pictureinpicture/tests/browser.ini b/toolkit/components/pictureinpicture/tests/browser.ini
new file mode 100644
index 0000000000..a11f4c7c5b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser.ini
@@ -0,0 +1,87 @@
+[DEFAULT]
+support-files =
+ click-event-helper.js
+ head.js
+ test-button-overlay.html
+ test-opaque-overlay.html
+ test-page.html
+ test-page-with-iframe.html
+ test-page-with-sound.html
+ test-pointer-events-none.html
+ test-transparent-overlay-1.html
+ test-transparent-overlay-2.html
+ test-video-selection.html
+ test-reversed.html
+ test-media-stream.html
+ test-video.mp4
+ test-video-cropped.mp4
+ test-video-vertical.mp4
+ test-video-long.mp4
+ short.mp4
+ ../../../../dom/media/test/gizmo.mp4
+ ../../../../dom/media/test/owl.mp3
+
+prefs =
+ media.videocontrols.picture-in-picture.enabled=true
+ media.videocontrols.picture-in-picture.allow-multiple=false
+ media.videocontrols.picture-in-picture.video-toggle.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.testing=true
+ media.videocontrols.picture-in-picture.video-toggle.always-show=true
+ media.videocontrols.picture-in-picture.video-toggle.has-used=true
+
+[browser_cannotTriggerFromContent.js]
+[browser_closePipPause.js]
+[browser_contextMenu.js]
+skip-if = os == "linux" && bits == 64 && os_version == "18.04" # Bug 1569205
+[browser_cornerSnapping.js]
+run-if = os == "mac"
+[browser_closePlayer.js]
+[browser_closeTab.js]
+[browser_dblclickFullscreen.js]
+[browser_flipIconWithRTL.js]
+[browser_mediaStreamVideos.js]
+[browser_durationChange.js]
+[browser_fullscreen.js]
+skip-if = (os == "mac" && debug) || os == "linux" #Bug 1566173, Bug 1664667
+[browser_keyboardShortcut.js]
+[browser_mouseButtonVariation.js]
+skip-if = debug
+[browser_noToggleOnAudio.js]
+[browser_playerControls.js]
+[browser_multiPip.js]
+[browser_removeVideoElement.js]
+[browser_rerequestPiP.js]
+[browser_resizeVideo.js]
+skip-if = os == 'linux' # Bug 1594223
+[browser_reversePiP.js]
+[browser_saveLastPiPLoc.js]
+skip-if =
+ os == "linux" # Bug 1673465
+ os == "win" && bits == 64 && debug # Bug 1683002
+[browser_shortcutsAfterFocus.js]
+skip-if = os == "win" && bits == 64 && debug # Bug 1683002
+[browser_showMessage.js]
+[browser_smallVideoLayout.js]
+skip-if = os == "win" && bits == 64 && debug # Bug 1683002
+[browser_stripVideoStyles.js]
+[browser_tabIconOverlayPiP.js]
+[browser_thirdPartyIframe.js]
+[browser_toggleAfterTabTearOutIn.js]
+skip-if = (os == 'linux' && bits == 64) || (os == 'mac' && !asan && !debug) # Bug 1605546
+[browser_toggleButtonOverlay.js]
+skip-if = true # Bug 1546455
+[browser_toggleMode_2.js]
+skip-if = (os == 'linux') # Bug 1654971
+[browser_toggleOnInsertedVideo.js]
+[browser_toggleOpaqueOverlay.js]
+skip-if = true # Bug 1546455
+[browser_togglePointerEventsNone.js]
+skip-if = true # Bug 1664920, Bug 1628777
+[browser_togglePolicies.js]
+skip-if = os == "linux" && bits == 64 # Bug 1605565
+[browser_toggleSimple.js]
+skip-if = os == 'linux' # Bug 1546455
+[browser_toggleTransparentOverlay-1.js]
+[browser_toggleTransparentOverlay-2.js]
+skip-if = os == 'linux' && (debug || asan) # Bug 1546930
+[browser_videoSelection.js]
diff --git a/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js
new file mode 100644
index 0000000000..4c3d32b474
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the MozTogglePictureInPicture event is ignored if
+ * fired by unprivileged web content.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // For now, the easiest way to ensure that this didn't happen is to fail
+ // if we receive the PictureInPicture:Request message.
+ const MESSAGE = "PictureInPicture:Request";
+ let sawMessage = false;
+ let listener = msg => {
+ sawMessage = true;
+ };
+ browser.messageManager.addMessageListener(MESSAGE, listener);
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.wrappedJSObject.fireEvents();
+ });
+ browser.messageManager.removeMessageListener(MESSAGE, listener);
+ ok(!sawMessage, "Got PictureInPicture:Request message unexpectedly.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closePipPause.js b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js
new file mode 100644
index 0000000000..b87888e411
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that MediaStream videos are not paused when closing
+ * the PiP window.
+ */
+add_task(async function test_close_mediaStreamVideos() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_ROOT + "test-media-stream.html",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to both testing videos
+ let newVideo = content.document.createElement("video");
+ newVideo.src = "test-video.mp4";
+ newVideo.id = "media-stream-video";
+ content.document.body.appendChild(newVideo);
+ newVideo.loop = true;
+ });
+ await ensureVideosReady(browser);
+
+ // Modify both the "with-controls" and "no-controls" videos so that they mirror
+ // the new video that we just added via MediaStream.
+ await SpecialPowers.spawn(browser, [], async () => {
+ let newVideo = content.document.getElementById("media-stream-video");
+ newVideo.play();
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let testedVideo = content.document.createElement("video");
+ testedVideo.id = videoID;
+ testedVideo.srcObject = newVideo.mozCaptureStream().clone();
+ content.document.body.prepend(testedVideo);
+ if (
+ testedVideo.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA
+ ) {
+ info(`Waiting for 'canplaythrough' for '${testedVideo.id}'`);
+ await ContentTaskUtils.waitForEvent(testedVideo, "canplaythrough");
+ }
+ testedVideo.play();
+ }
+ });
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is not paused in PiP window."
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is not paused after closing PiP window."
+ );
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closePlayer.js b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js
new file mode 100644
index 0000000000..9b1b0a0047
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that closing with unpip leaves the video playing but the close button
+ * will pause the video.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let playVideo = () => {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).play();
+ });
+ };
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+ await playVideo();
+
+ // Try the unpip button.
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ // Try the close button.
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(await isVideoPaused(browser, videoID), "The video is paused");
+
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_closeTab.js b/toolkit/components/pictureinpicture/tests/browser_closeTab.js
new file mode 100644
index 0000000000..e8c9f59d82
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closeTab.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the tab that's hosting a <video> that's opened in a
+ * Picture-in-Picture window is closed, that the Picture-in-Picture
+ * window is also closed.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ BrowserTestUtils.removeTab(tab);
+ await pipClosed;
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
new file mode 100644
index 0000000000..b762049844
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
@@ -0,0 +1,238 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Opens up the content area context menu on a video loaded in a
+ * browser.
+ *
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to open the context
+ * menu with.
+ *
+ * @returns Promise
+ * @resolves With the context menu DOM node once opened.
+ */
+async function openContextMenu(browser, videoID) {
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + videoID,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShownPromise;
+ return contextMenu;
+}
+
+/**
+ * Closes the content area context menu.
+ *
+ * @param {Element} contextMenu The content area context menu opened with
+ * openContextMenu.
+ *
+ * @returns Promise
+ * @resolves With undefined
+ */
+async function closeContextMenu(contextMenu) {
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+}
+
+/**
+ * Tests that Picture-in-Picture can be opened and closed through the
+ * context menu
+ */
+add_task(async () => {
+ for (const videoId of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoId} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await openContextMenu(browser, videoId);
+
+ info("Context menu is open.");
+
+ const pipMenuItemId = "context-video-pictureinpicture";
+ let menuItem = document.getElementById(pipMenuItemId);
+
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+
+ await EventUtils.synthesizeMouseAtCenter(menuItem, {});
+
+ await SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ info("PiP player is now open.");
+
+ await openContextMenu(browser, videoId);
+
+ info("Context menu is open again.");
+
+ await EventUtils.synthesizeMouseAtCenter(menuItem, {});
+
+ await SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return !video.isCloningElementVisually;
+ }, "Video has stopped being cloned.");
+ });
+ }
+ );
+ }
+});
+
+/**
+ * Tests that the Picture-in-Picture context menu is correctly updated
+ * based on the Picture-in-Picture state of the video.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let menuItem = document.getElementById(
+ "context-video-pictureinpicture"
+ );
+ let menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "true",
+ "Picture-in-Picture should be checked."
+ );
+ await closeContextMenu(menu);
+
+ let videoNotCloning = SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return !video.isCloningElementVisually;
+ }, "Video has stopped being cloned.");
+ }
+ );
+ pipWin.close();
+ await videoNotCloning;
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to the video that we're testing.
+ let newVideo = content.document.createElement("video");
+ content.document.body.appendChild(newVideo);
+
+ let testedVideo = content.document.getElementById(videoID);
+ newVideo.src = testedVideo.src;
+
+ testedVideo.srcObject = newVideo.mozCaptureStream();
+ await newVideo.play();
+ await testedVideo.play();
+
+ await newVideo.pause();
+ await testedVideo.pause();
+ });
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should be showing Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "false",
+ "Picture-in-Picture should be unchecked."
+ );
+ await closeContextMenu(menu);
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video has started being cloned.");
+ });
+
+ menu = await openContextMenu(browser, videoID);
+ Assert.ok(
+ !menuItem.hidden,
+ "Should show Picture-in-Picture menu item."
+ );
+ Assert.equal(
+ menuItem.getAttribute("checked"),
+ "true",
+ "Picture-in-Picture should be checked."
+ );
+ await closeContextMenu(menu);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js
new file mode 100644
index 0000000000..7ebfafc721
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js
@@ -0,0 +1,271 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const FLOAT_OFFSET = 50;
+const CHANGE_OFFSET = 30;
+const DECREASE_OFFSET = FLOAT_OFFSET - CHANGE_OFFSET;
+const INCREASE_OFFSET = FLOAT_OFFSET + CHANGE_OFFSET;
+/**
+ * This function tests the PiP corner snapping feature.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged left
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + DECREASE_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged up
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + DECREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged right
+ * should snap into top right corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + INCREASE_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth,
+ "Window should be on the right"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 2), dragged down
+ * should snap into bottom left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop + INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top right corner(quadrant 1), dragged down
+ * should snap into bottom right corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop + FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop + INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth,
+ "Window should be on the right"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 4), dragged left
+ * should snap into bottom left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft +
+ pipWin.screen.availWidth -
+ pipWin.innerWidth -
+ INCREASE_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight,
+ "Window should be on the bottom"
+ );
+
+ /**
+ * pipWin floating in top left corner(quadrant 3), dragged up
+ * should snap into top left corner
+ */
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ FLOAT_OFFSET
+ );
+ EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin);
+ pipWin.moveTo(
+ pipWin.screen.availLeft + FLOAT_OFFSET,
+ pipWin.screen.availTop +
+ pipWin.screen.availHeight -
+ pipWin.innerHeight -
+ INCREASE_OFFSET
+ );
+ EventUtils.sendMouseEvent(
+ {
+ type: "mouseup",
+ metaKey: true,
+ },
+ controls,
+ pipWin
+ );
+ Assert.equal(
+ pipWin.screenX,
+ pipWin.screen.availLeft,
+ "Window should be on the left"
+ );
+ Assert.equal(
+ pipWin.screenY,
+ pipWin.screen.availTop,
+ "Window should be on the top"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
new file mode 100644
index 0000000000..c915a6f0e7
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that double-clicking on the Picture-in-Picture player window
+ * causes it to fullscreen, and that pressing Escape allows us to exit
+ * fullscreen.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ // First, we'll test exiting fullscreen by double-clicking again
+ // on the document body.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ // Now we double-click to re-enter fullscreen.
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ await EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to re-enter fullscreen."
+ );
+
+ // Finally, we check that hitting Escape lets the user leave
+ // fullscreen.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Pressing Escape caused us to exit fullscreen."
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ pipWin.close();
+ await pipClosed;
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_durationChange.js b/toolkit/components/pictureinpicture/tests/browser_durationChange.js
new file mode 100644
index 0000000000..69397d8959
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_durationChange.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the visibility of the toggle will be
+ * recomputed after durationchange events fire.
+ */
+add_task(async function test_durationChange() {
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ],
+ });
+
+ // First, ensure that the toggle doesn't show up for these
+ // short videos by default.
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: false },
+ "no-controls": { canToggle: false },
+ });
+
+ // Now cause the video to change sources, which should fire a
+ // durationchange event. The longer video should qualify us for
+ // displaying the toggle.
+ await testToggle(
+ TEST_PAGE,
+ {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = content.document.getElementById(videoID);
+ video.src = "gizmo.mp4";
+ let durationChangePromise = ContentTaskUtils.waitForEvent(
+ video,
+ "durationchange"
+ );
+
+ video.load();
+ await durationChangePromise;
+ }
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js
new file mode 100644
index 0000000000..cfb91440f5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * The goal of this test is to check that the icon on the PiP button mirrors
+ * and the explainer text that shows up before the first time PiP is used
+ * right aligns when the browser is set to a RtL mode
+ *
+ * The browser will create a tab and open a video using PiP
+ * then the tests check that the components change their appearance accordingly
+ *
+ */
+
+/**
+ * This test ensures that the default ltr is working as intended
+ */
+add_task(async function test_ltr_toggle() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ for (let videoId of ["with-controls", "no-controls"]) {
+ let localeDir = await SpecialPowers.spawn(browser, [videoId], id => {
+ let video = content.document.getElementById(id);
+ let videocontrols = video.openOrClosedShadowRoot.firstChild;
+ return videocontrols.getAttribute("localedir");
+ });
+
+ Assert.equal(localeDir, "ltr", "Got the right localedir");
+ }
+ }
+ );
+});
+
+/**
+ * This test ensures that the components flip correctly when rtl is set
+ */
+add_task(async function test_rtl_toggle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["intl.l10n.pseudo", "bidi"]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ for (let videoId of ["with-controls", "no-controls"]) {
+ let localeDir = await SpecialPowers.spawn(browser, [videoId], id => {
+ let video = content.document.getElementById(id);
+ let videocontrols = video.openOrClosedShadowRoot.firstChild;
+ return videocontrols.getAttribute("localedir");
+ });
+
+ Assert.equal(localeDir, "rtl", "Got the right localedir");
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
new file mode 100644
index 0000000000..5f80c56307
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const VIDEOS = ["with-controls", "no-controls"];
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden when
+ * a video with or without controls is made fullscreen.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ for (let videoID of VIDEOS) {
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = this.content.document.getElementById(videoID);
+ video.requestFullscreen();
+ });
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.is_hidden(toggle),
+ "Toggle should be hidden in fullscreen mode."
+ );
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden if an
+ * ancestor of a video (in this case, the document body) is made
+ * to be the fullscreen element.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.body.requestFullscreen();
+ });
+ });
+
+ for (let videoID of VIDEOS) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.is_hidden(toggle),
+ "Toggle should be hidden in fullscreen mode."
+ );
+ });
+ }
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ );
+});
+
+/**
+ * Tests that the Picture-In-Picture window is closed when something
+ * is fullscreened
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ for (let videoId of VIDEOS) {
+ let pipWin = await triggerPictureInPicture(browser, videoId);
+ ok(pipWin, "Got Picture-In-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+
+ // need to focus first, since fullscreen request will be blocked otherwise
+ await SimpleTest.promiseFocus(window);
+
+ await promiseFullscreenEntered(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.body.requestFullscreen();
+ });
+ });
+
+ await pipClosed;
+ ok(pipWin.closed, "Picture-In-Picture successfully closed.");
+
+ await promiseFullscreenExited(window, async () => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
new file mode 100644
index 0000000000..b2f83205fa
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the user keys in the keyboard shortcut for
+ * Picture-in-Picture, then the first video on the currently
+ * focused page will be opened in the player window.
+ */
+add_task(async function test_pip_keyboard_shortcut() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ // In test-page.html, the "with-controls" video is the first one that
+ // appears in the DOM, so this is what we expect to open via the keyboard
+ // shortcut.
+ const VIDEO_ID = "with-controls";
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let videoReady = SpecialPowers.spawn(
+ browser,
+ [VIDEO_ID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ }
+ );
+
+ if (AppConstants.platform == "macosx") {
+ EventUtils.synthesizeKey("]", {
+ accelKey: true,
+ shiftKey: true,
+ altKey: true,
+ });
+ } else {
+ EventUtils.synthesizeKey("]", { accelKey: true, shiftKey: true });
+ }
+
+ let pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, VIDEO_ID, pipWin, false);
+
+ // Reopen PiP Window
+ pipWin = await triggerPictureInPicture(browser, VIDEO_ID);
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ if (AppConstants.platform == "macosx") {
+ EventUtils.synthesizeKey(
+ "]",
+ {
+ accelKey: true,
+ shiftKey: true,
+ altKey: true,
+ },
+ pipWin
+ );
+ } else {
+ EventUtils.synthesizeKey(
+ "]",
+ { accelKey: true, shiftKey: true },
+ pipWin
+ );
+ }
+
+ await BrowserTestUtils.windowClosed(pipWin);
+
+ ok(pipWin.closed, "Picture-in-Picture window closed.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js
new file mode 100644
index 0000000000..46b36a3a6e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that the media stream video format has functional
+ * support for PiP
+ */
+add_task(async function test_mediaStreamVideos() {
+ await testToggle(
+ TEST_ROOT + "test-media-stream.html",
+ {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Construct a new video element, and capture a stream from it
+ // to redirect to both testing videos. Create the captureStreams after
+ // we have metadata so tracks are immediately available, but wait with
+ // playback until the setup is done.
+
+ function logEvent(element, ev) {
+ element.addEventListener(ev, () =>
+ info(
+ `${element.id} got event ${ev}. currentTime=${element.currentTime}`
+ )
+ );
+ }
+
+ const newVideo = content.document.createElement("video");
+ newVideo.id = "new-video";
+ newVideo.src = "test-video.mp4";
+ newVideo.preload = "auto";
+ logEvent(newVideo, "timeupdate");
+ logEvent(newVideo, "ended");
+ content.document.body.appendChild(newVideo);
+ await ContentTaskUtils.waitForEvent(newVideo, "loadedmetadata");
+
+ const mediastreamPlayingPromises = [];
+ for (let videoID of ["with-controls", "no-controls"]) {
+ const testedVideo = content.document.createElement("video");
+ testedVideo.id = videoID;
+ testedVideo.srcObject = newVideo.mozCaptureStream();
+ testedVideo.play();
+ mediastreamPlayingPromises.push(
+ new Promise(r => (testedVideo.onplaying = r))
+ );
+ content.document.body.prepend(testedVideo);
+ }
+
+ await newVideo.play();
+ await Promise.all(mediastreamPlayingPromises);
+ newVideo.pause();
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js
new file mode 100644
index 0000000000..f424093215
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the user mousedown's on a Picture-in-Picture toggle,
+ * but then mouseup's on something completely different, that we still
+ * open a Picture-in-Picture window, and that the mouse button events are
+ * all suppressed. Also ensures that a subsequent click on the document
+ * body results in all mouse button events firing normally.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+ let videoID = "no-controls";
+
+ await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle, which is necessary
+ // if we want to click on the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Waiting for toggle to become visible");
+ await toggleOpacityReachesThreshold(browser, videoID, "hoverVideo");
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+
+ // The toggle center, because of how it slides out, is actually outside
+ // of the bounds of a click event. For now, we move the mouse in by a
+ // hard-coded 15 pixels along the x and y axis to achieve the hover.
+ let toggleLeft = toggleClientRect.left + 15;
+ let toggleTop = toggleClientRect.top + 15;
+
+ info(
+ "Clicking on toggle, and expecting a Picture-in-Picture window to open"
+ );
+ // We need to wait for the window to have completed loading before we
+ // can close it as the document's type required by closeWindow may not
+ // be available.
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleLeft,
+ toggleTop,
+ {
+ type: "mousedown",
+ },
+ browser
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ 1,
+ 1,
+ {
+ type: "mouseup",
+ },
+ browser
+ );
+
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+
+ await BrowserTestUtils.closeWindow(win);
+ await assertSawMouseEvents(browser, false);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_multiPip.js b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
new file mode 100644
index 0000000000..f4f48fc7a6
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function createTab() {
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PAGE,
+ waitForLoad: true,
+ });
+}
+
+function getTelemetryMaxPipCount(resetMax = false) {
+ const scalarData = Services.telemetry.getSnapshotForScalars("main", resetMax)
+ .parent;
+ return scalarData["pictureinpicture.most_concurrent_players"];
+}
+
+/**
+ * Set pref for multiple PiP support first
+ */
+add_task(async () => {
+ return SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.allow-multiple", true]],
+ });
+});
+
+/**
+ * Tests that multiple PiPs can be opened and closed in a single tab
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let firstPip = await triggerPictureInPicture(browser, "with-controls");
+ ok(firstPip, "Got first PiP window");
+
+ let secondPip = await triggerPictureInPicture(browser, "no-controls");
+ ok(secondPip, "Got second PiP window");
+
+ await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false);
+ info("First PiP was still open and is now closed");
+
+ await ensureMessageAndClosePiP(browser, "no-controls", secondPip, false);
+ info("Second PiP was still open and is now closed");
+ }
+ );
+});
+
+/**
+ * Tests that multiple PiPs can be opened and closed across different tabs
+ */
+add_task(async () => {
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ gBrowser.selectedTab = firstTab;
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ gBrowser.selectedTab = secondTab;
+
+ let secondPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "with-controls",
+ firstPip,
+ false
+ );
+ info("First Picture-in-Picture window was open and is now closed.");
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "with-controls",
+ secondPip,
+ false
+ );
+ info("Second Picture-in-Picture window was open and is now closed.");
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+});
+
+/**
+ * Tests that when a tab is closed; that only PiPs originating from this tab
+ * are closed as well
+ */
+add_task(async () => {
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ let secondPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ let firstClosed = BrowserTestUtils.domWindowClosed(firstPip);
+ BrowserTestUtils.removeTab(firstTab);
+ await firstClosed;
+ info("First PiP closed after closing the first tab");
+
+ await assertVideoIsBeingCloned(secondTab.linkedBrowser, "with-controls");
+ info("Second PiP is still open after first tab close");
+
+ let secondClosed = BrowserTestUtils.domWindowClosed(secondPip);
+ BrowserTestUtils.removeTab(secondTab);
+ await secondClosed;
+ info("Second PiP closed after closing the second tab");
+});
+
+/**
+ * Check that correct number of pip players are recorded for Telemetry
+ * tracking
+ */
+add_task(async () => {
+ // run this to flush recorded values from previous tests
+ getTelemetryMaxPipCount(true);
+
+ let firstTab = await createTab();
+ let secondTab = await createTab();
+
+ gBrowser.selectedTab = firstTab;
+
+ let firstPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(firstPip, "Got first PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(true),
+ 1,
+ "There should only be 1 PiP window"
+ );
+
+ let secondPip = await triggerPictureInPicture(
+ firstTab.linkedBrowser,
+ "no-controls"
+ );
+ ok(secondPip, "Got second PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(true),
+ 2,
+ "There should be 2 PiP windows"
+ );
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "no-controls",
+ secondPip,
+ false
+ );
+ info("Second PiP was open and is now closed");
+
+ gBrowser.selectedTab = secondTab;
+
+ let thirdPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "with-controls"
+ );
+ ok(thirdPip, "Got third PiP window");
+
+ let fourthPip = await triggerPictureInPicture(
+ secondTab.linkedBrowser,
+ "no-controls"
+ );
+ ok(fourthPip, "Got fourth PiP window");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(false),
+ 3,
+ "There should now be 3 PiP windows"
+ );
+
+ gBrowser.selectedTab = firstTab;
+
+ await ensureMessageAndClosePiP(
+ firstTab.linkedBrowser,
+ "with-controls",
+ firstPip,
+ false
+ );
+ info("First PiP was open, it is now closed.");
+
+ gBrowser.selectedTab = secondTab;
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "with-controls",
+ thirdPip,
+ false
+ );
+ info("Third PiP was open, it is now closed.");
+
+ await ensureMessageAndClosePiP(
+ secondTab.linkedBrowser,
+ "no-controls",
+ fourthPip,
+ false
+ );
+ info("Fourth PiP was open, it is now closed.");
+
+ Assert.equal(
+ getTelemetryMaxPipCount(false),
+ 3,
+ "Max PiP count should still be 3"
+ );
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
new file mode 100644
index 0000000000..0d16bdc9e2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a <video> element only has audio, and no video
+ * frames, that we do not show the toggle.
+ */
+add_task(async function test_no_toggle_on_audio() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "owl.mp3",
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ await SimpleTest.promiseFocus(browser);
+
+ // The media player document we create for owl.mp3 inserts a <video>
+ // element pointed at the .mp3 file, which is what we're trying to
+ // test for. The <video> element does not get an ID created for it
+ // though, so we sneak one in with SpecialPowers.spawn so that we
+ // can use testToggleHelper (which requires an ID).
+ //
+ // testToggleHelper also wants click-event-helper.js loaded in the
+ // document, so we insert that too.
+ const VIDEO_ID = "video-element";
+ const SCRIPT_SRC = "click-event-helper.js";
+ await SpecialPowers.spawn(browser, [VIDEO_ID, SCRIPT_SRC], async function(
+ videoID,
+ scriptSrc
+ ) {
+ let video = content.document.querySelector("video");
+ video.id = videoID;
+
+ let script = content.document.createElement("script");
+ script.src = scriptSrc;
+ content.document.head.appendChild(script);
+ });
+
+ await testToggleHelper(browser, VIDEO_ID, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
new file mode 100644
index 0000000000..a2684dd451
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality of the various controls for the Picture-in-Picture
+ * video window.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.audio-toggle.enabled", true],
+ ],
+ });
+ let videoID = "with-controls";
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let isVideoMuted = () => {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+ };
+ let waitForVideoEvent = eventType => {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+ };
+
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ let playPause = pipWin.document.getElementById("playpause");
+ let audioButton = pipWin.document.getElementById("audio");
+
+ // Try the pause button
+ let pausedPromise = waitForVideoEvent("pause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await pausedPromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // Try the play button
+ let playPromise = waitForVideoEvent("play");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await playPromise;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is playing.");
+
+ // Try the mute button
+ let mutedPromise = waitForVideoEvent("volumechange");
+ ok(!(await isVideoMuted()), "The audio is playing.");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(), "The audio is muted.");
+
+ // Try the unmute button
+ let unmutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await unmutedPromise;
+ ok(!(await isVideoMuted()), "The audio is playing.");
+
+ // Try the unpip button.
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Try the close button.
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js
new file mode 100644
index 0000000000..d9622dc3f0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a <video> element is being displayed in a
+ * Picture-in-Picture window, that the window closes if that
+ * original <video> is ever removed from the DOM.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ Assert.ok(pipWin, "Got PiP window.");
+
+ // First, let's make sure that removing the _other_ video doesn't cause
+ // the special event to fire, nor the PiP window to close.
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let doc = content.document;
+ let otherVideo = doc.querySelector(`video:not([id="${videoID}"])`);
+ let eventFired = false;
+
+ let listener = e => {
+ eventFired = true;
+ };
+
+ docShell.chromeEventHandler.addEventListener(
+ "MozStopPictureInPicture",
+ listener,
+ {
+ capture: true,
+ }
+ );
+ otherVideo.remove();
+ Assert.ok(
+ !eventFired,
+ "Should not have seen MozStopPictureInPicture for other video"
+ );
+ docShell.chromeEventHandler.removeEventListener(
+ "MozStopPictureInPicture",
+ listener,
+ {
+ capture: true,
+ }
+ );
+ });
+
+ Assert.ok(!pipWin.closed, "PiP window should still be open.");
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let doc = content.document;
+ let video = doc.querySelector(`#${videoID}`);
+
+ let promise = ContentTaskUtils.waitForEvent(
+ docShell.chromeEventHandler,
+ "MozStopPictureInPicture",
+ { capture: true }
+ );
+ video.remove();
+ await promise;
+ });
+
+ try {
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.closed,
+ "Player window closed."
+ );
+ } finally {
+ if (!pipWin.closed) {
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ }
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js
new file mode 100644
index 0000000000..27d2861bca
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if a pre-existing Picture-in-Picture window exists, and a
+ * different video is requested to open in Picture-in-Picture, that the
+ * original Picture-in-Picture window closes and a new one is opened.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipWin2 = await triggerPictureInPicture(browser, "no-controls");
+ await pipClosed;
+ ok(true, "Original Picture-in-Picture window closed.");
+
+ pipClosed = BrowserTestUtils.domWindowClosed(pipWin2);
+ pipWin2.close();
+ await pipClosed;
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
new file mode 100644
index 0000000000..917bceb9ac
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
@@ -0,0 +1,263 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Run the resize test on a player window.
+ *
+ * @param browser (xul:browser)
+ * The browser that has the source video.
+ *
+ * @param videoID (string)
+ * The id of the video in the browser to test.
+ *
+ * @param pipWin (player window)
+ * A player window to run the tests on.
+ *
+ * @param opts (object)
+ * The options for the test.
+ *
+ * pinX (boolean):
+ * If true, the video's X position shouldn't change when resized.
+ *
+ * pinY (boolean):
+ * If true, the video's Y position shouldn't change when resized.
+ */
+async function testVideo(browser, videoID, pipWin, { pinX, pinY } = {}) {
+ async function switchVideoSource(src) {
+ let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ await ContentTask.spawn(
+ browser,
+ { src, videoID },
+ async ({ src, videoID }) => {
+ let doc = content.document;
+ let video = doc.getElementById(videoID);
+ video.src = src;
+ }
+ );
+ await videoResized;
+ }
+
+ /**
+ * Check the new screen position against the previous one. When
+ * pinX or pinY is true then the top left corner is checked in that
+ * dimension. Otherwise, the bottom right corner is checked.
+ *
+ * The video position is determined by the screen edge it's closest
+ * to, so in the default bottom right its bottom right corner should
+ * match the previous video's bottom right corner. For the top left,
+ * the top left corners should match.
+ */
+ function checkPosition(
+ previousScreenX,
+ previousScreenY,
+ previousWidth,
+ previousHeight,
+ newScreenX,
+ newScreenY,
+ newWidth,
+ newHeight
+ ) {
+ if (pinX || previousScreenX == 0) {
+ Assert.equal(
+ previousScreenX,
+ newScreenX,
+ "New video is still in the same X position"
+ );
+ } else {
+ Assert.less(
+ Math.abs(previousScreenX + previousWidth - (newScreenX + newWidth)),
+ 2,
+ "New video ends at the same screen X position (within 1 pixel)"
+ );
+ }
+ if (pinY) {
+ Assert.equal(
+ previousScreenY,
+ newScreenY,
+ "New video is still in the same Y position"
+ );
+ } else {
+ Assert.equal(
+ previousScreenY + previousHeight,
+ newScreenY + newHeight,
+ "New video ends at the same screen Y position"
+ );
+ }
+ }
+
+ Assert.ok(pipWin, "Got PiP window.");
+
+ let initialWidth = pipWin.innerWidth;
+ let initialHeight = pipWin.innerHeight;
+ let initialAspectRatio = initialWidth / initialHeight;
+ Assert.equal(
+ Math.floor(initialAspectRatio * 100),
+ 177, // 16 / 9 = 1.777777777
+ "Original aspect ratio is 16:9"
+ );
+
+ // Store the window position for later.
+ let initialScreenX = pipWin.mozInnerScreenX;
+ let initialScreenY = pipWin.mozInnerScreenY;
+
+ await switchVideoSource("test-video-cropped.mp4");
+
+ let resizedWidth = pipWin.innerWidth;
+ let resizedHeight = pipWin.innerHeight;
+ let resizedAspectRatio = resizedWidth / resizedHeight;
+ Assert.equal(
+ Math.floor(resizedAspectRatio * 100),
+ 133, // 4 / 3 = 1.333333333
+ "Resized aspect ratio is 4:3"
+ );
+ Assert.less(resizedWidth, initialWidth, "Resized video has smaller width");
+ Assert.equal(
+ resizedHeight,
+ initialHeight,
+ "Resized video is the same vertically"
+ );
+
+ let resizedScreenX = pipWin.mozInnerScreenX;
+ let resizedScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ initialScreenX,
+ initialScreenY,
+ initialWidth,
+ initialHeight,
+ resizedScreenX,
+ resizedScreenY,
+ resizedWidth,
+ resizedHeight
+ );
+
+ await switchVideoSource("test-video-vertical.mp4");
+
+ let verticalWidth = pipWin.innerWidth;
+ let verticalHeight = pipWin.innerHeight;
+ let verticalAspectRatio = verticalWidth / verticalHeight;
+
+ if (verticalWidth == 136) {
+ // The video is minimun width allowed
+ Assert.equal(
+ Math.floor(verticalAspectRatio * 100),
+ 56, // 1 / 2 = 0.5
+ "Vertical aspect ratio is 1:2"
+ );
+ } else {
+ Assert.equal(
+ Math.floor(verticalAspectRatio * 100),
+ 50, // 1 / 2 = 0.5
+ "Vertical aspect ratio is 1:2"
+ );
+ }
+
+ Assert.less(verticalWidth, resizedWidth, "Vertical video width shrunk");
+ Assert.equal(
+ verticalHeight,
+ initialHeight,
+ "Vertical video height matches previous height"
+ );
+
+ let verticalScreenX = pipWin.mozInnerScreenX;
+ let verticalScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ resizedScreenX,
+ resizedScreenY,
+ resizedWidth,
+ resizedHeight,
+ verticalScreenX,
+ verticalScreenY,
+ verticalWidth,
+ verticalHeight
+ );
+
+ await switchVideoSource("test-video.mp4");
+
+ let restoredWidth = pipWin.innerWidth;
+ let restoredHeight = pipWin.innerHeight;
+ let restoredAspectRatio = restoredWidth / restoredHeight;
+ Assert.equal(
+ Math.floor(restoredAspectRatio * 100),
+ 177,
+ "Restored aspect ratio is still 16:9"
+ );
+ Assert.less(
+ Math.abs(initialWidth - pipWin.innerWidth),
+ 2,
+ "Restored video has its original width"
+ );
+ Assert.equal(
+ initialHeight,
+ pipWin.innerHeight,
+ "Restored video has its original height"
+ );
+
+ let restoredScreenX = pipWin.mozInnerScreenX;
+ let restoredScreenY = pipWin.mozInnerScreenY;
+ checkPosition(
+ initialScreenX,
+ initialScreenY,
+ initialWidth,
+ initialHeight,
+ restoredScreenX,
+ restoredScreenY,
+ restoredWidth,
+ restoredHeight
+ );
+}
+
+/**
+ * Tests that if a <video> element is resized the Picture-in-Picture window
+ * will be resized to match the new dimensions.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ await testVideo(browser, videoID, pipWin);
+
+ pipWin.moveTo(0, 0);
+
+ await testVideo(browser, videoID, pipWin, { pinX: true, pinY: true });
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+ }
+});
+
+/**
+ * Tests that the RTL video starts on the left and is pinned in the X dimension.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ await testVideo(browser, videoID, pipWin, { pinX: true });
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+ }
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_reversePiP.js b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js
new file mode 100644
index 0000000000..a8ae20166f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js
@@ -0,0 +1,145 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the PiP toggle button is not flipped
+ * on certain websites (such as whereby.com).
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "test-reversed.html",
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let videoID = "reversed";
+
+ // Test the toggle button
+ await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ let toggleFlippedAttribute = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return controlsOverlay.classList.contains("hovering");
+ }, "Waiting for the hovering state to be set on the video.");
+
+ return shadowRoot.firstChild.getAttribute("flipped");
+ }
+ );
+
+ // The "flipped" attribute should be set on the toggle button (when applicable).
+ Assert.equal(toggleFlippedAttribute, "true");
+ }
+ );
+});
+
+/**
+ * Tests that the "This video is playing in Picture-in-Picture" message
+ * as well as the video playing in PiP are both not flipped on certain sites
+ * (such as whereby.com)
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_ROOT + "test-reversed.html",
+ },
+ async browser => {
+ /**
+ * A helper function used to get the "flipped" attribute of the video's shadowRoot's first child.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ */
+ async function getFlippedAttribute(browser, videoID) {
+ let videoFlippedAttribute = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ return shadowRoot.firstChild.getAttribute("flipped");
+ }
+ );
+ return videoFlippedAttribute;
+ }
+
+ /**
+ * A helper function that returns the transform.a of the video being played in PiP.
+ * @param {Element} playerBrowser The <xul:browser> of the PiP window
+ */
+ async function getPiPVideoTransform(playerBrowser) {
+ let pipVideoTransform = await SpecialPowers.spawn(
+ playerBrowser,
+ [],
+ async () => {
+ let video = content.document.querySelector("video");
+ return video.getTransformToViewport().a;
+ }
+ );
+ return pipVideoTransform;
+ }
+
+ await ensureVideosReady(browser);
+
+ let videoID = "reversed";
+
+ let videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should not be set initially.
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, "reversed");
+ Assert.equal(videoFlippedAttribute, "true"); // The "flipped" value should be set once the PiP window is opened (when applicable).
+
+ let playerBrowser = pipWin.document.getElementById("browser");
+ let pipVideoTransform = await getPiPVideoTransform(playerBrowser);
+ Assert.equal(pipVideoTransform, -1);
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, "reversed");
+ Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should be removed after closing PiP.
+
+ // Now we want to test that regular (not-reversed) videos are unaffected
+ videoID = "not-reversed";
+ videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null);
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+
+ videoFlippedAttribute = await getFlippedAttribute(browser, videoID);
+ Assert.equal(videoFlippedAttribute, null);
+
+ playerBrowser = pipWin.document.getElementById("browser");
+ pipVideoTransform = await getPiPVideoTransform(playerBrowser);
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
new file mode 100644
index 0000000000..fbdf7fb456
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
@@ -0,0 +1,362 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This function tests that the browser saves the last location of size of
+ * the PiP window and will open the next PiP window in the same location
+ * with the size. It adjusts for aspect ratio by keeping the same height and
+ * adjusting the width of the PiP window.
+ */
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ // Function to switch video source.
+ async browser => {
+ async function switchVideoSource(src) {
+ let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ await ContentTask.spawn(browser, { src }, async ({ src }) => {
+ let doc = content.document;
+ let video = doc.getElementById("with-controls");
+ video.src = src;
+ });
+ await videoResized;
+ }
+ // This function is used because the rounding of the width can be off
+ // by about 1 pixel sometimes so this checks that val1 and val2 are
+ // within 1 pixel
+ function checkIfEqual(val1, val2, str) {
+ let equal = Math.abs(val1 - val2);
+ if (equal <= 1) {
+ is(equal <= 1, true, str);
+ } else {
+ is(val1, val2, str);
+ }
+ }
+
+ // Used for clearing the size and location of the PiP window
+ const PLAYER_URI =
+ "chrome://global/content/pictureinpicture/player.xhtml";
+
+ // The PiP window now stores information between tests and needs to be
+ // cleared before the test begins
+ function clearSaved() {
+ let xulStore = Services.xulStore;
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", NaN);
+ xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", NaN);
+ }
+
+ function getAvailScreenSize(screen) {
+ let screenLeft = {},
+ screenTop = {},
+ screenWidth = {},
+ screenHeight = {};
+ screen.GetAvailRectDisplayPix(
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight
+ );
+ let fullLeft = {},
+ fullTop = {},
+ fullWidth = {},
+ fullHeight = {};
+ screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight);
+
+ // We have to divide these dimensions by the CSS scale factor for the
+ // display in order for the video to be positioned correctly on displays
+ // that are not at a 1.0 scaling.
+ let scaleFactor =
+ screen.contentsScaleFactor / screen.defaultCSSScaleFactor;
+ screenWidth.value *= scaleFactor;
+ screenHeight.value *= scaleFactor;
+ screenLeft.value =
+ (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value;
+ screenTop.value =
+ (screenTop.value - fullTop.value) * scaleFactor + fullTop.value;
+
+ return [
+ screenLeft.value,
+ screenTop.value,
+ screenWidth.value,
+ screenHeight.value,
+ ];
+ }
+
+ let screen = Cc["@mozilla.org/gfx/screenmanager;1"]
+ .getService(Ci.nsIScreenManager)
+ .screenForRect(1, 1, 1, 1);
+
+ let [
+ defaultX,
+ defaultY,
+ defaultWidth,
+ defaultHeight,
+ ] = getAvailScreenSize(screen);
+
+ // Default size of PiP window
+ let rightEdge = defaultX + defaultWidth;
+ let bottomEdge = defaultY + defaultHeight;
+
+ // tab height
+ // Used only for Linux as the PiP window has a tab
+ let tabHeight = 35;
+
+ // clear already saved information
+ clearSaved();
+
+ // Open PiP
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let defaultPiPWidth = pipWin.innerWidth;
+ let defaultPiPHeight = pipWin.innerHeight;
+
+ // Check that it is opened at default location
+ checkIfEqual(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ "Default PiP X location"
+ );
+ if (AppConstants.platform == "linux") {
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight - tabHeight,
+ "Default PiP Y location"
+ );
+ } else {
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ "Default PiP Y location"
+ );
+ }
+ checkIfEqual(pipWin.innerHeight, defaultPiPHeight, "Default PiP height");
+ checkIfEqual(pipWin.innerWidth, defaultPiPWidth, "Default PiP width");
+
+ let top = defaultY;
+ let left = defaultX;
+ pipWin.moveTo(left, top);
+ let height = pipWin.innerHeight / 2;
+ let width = pipWin.innerWidth / 2;
+ pipWin.resizeTo(width, height);
+
+ // CLose first PiP window and open another
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // PiP is opened at 0, 0 with size 1/4 default width and 1/4 default height
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(
+ pipWin.innerHeight,
+ height,
+ "Opened with 1/2 default height"
+ );
+ checkIfEqual(pipWin.innerWidth, width, "Opened with 1/2 default width");
+
+ // Mac and Linux did not allow moving to coordinates offscreen so this
+ // test is skipped on those platforms
+ if (AppConstants.platform == "win") {
+ // Move to -1111, -1111 and adjust size to 1/4 width and 1/4 height
+ left = -11111;
+ top = -11111;
+ pipWin.moveTo(left, top);
+ pipWin.resizeTo(pipWin.innerWidth / 4, pipWin.innerHeight / 4);
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // because the coordinates are off screen, the default size and location will be used
+ checkIfEqual(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ "Opened at default X location"
+ );
+ checkIfEqual(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ "Opened at default Y location"
+ );
+ checkIfEqual(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ "Opened at default PiP width"
+ );
+ checkIfEqual(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ "Opened at default PiP height"
+ );
+ }
+
+ // Linux doesn't handle switching the video source well and it will
+ // cause the tests to failed in unexpected ways. Possibly caused by
+ // bug 1594223 https://bugzilla.mozilla.org/show_bug.cgi?id=1594223
+ if (AppConstants.platform != "linux") {
+ // Save width and height for when aspect ratio is changed
+ height = pipWin.innerHeight;
+ width = pipWin.innerWidth;
+
+ left = 200;
+ top = 100;
+ pipWin.moveTo(left, top);
+
+ // Now switch the video so the video ratio is different
+ await switchVideoSource("test-video-cropped.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(
+ pipWin.innerHeight,
+ height,
+ "Opened height with previous width"
+ );
+ checkIfEqual(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ "Width is changed to adjust for aspect ration"
+ );
+
+ left = 300;
+ top = 300;
+ pipWin.moveTo(left, top);
+ pipWin.resizeTo(defaultPiPWidth / 2, defaultPiPHeight / 2);
+
+ // Save height for when aspect ratio is changed
+ height = pipWin.innerHeight;
+
+ // Now switch the video so the video ratio is different
+ await switchVideoSource("test-video.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(pipWin.screenX, left, "Opened at last X location");
+ checkIfEqual(pipWin.screenY, top, "Opened at last Y location");
+ checkIfEqual(pipWin.innerHeight, height, "Opened with previous height");
+ checkIfEqual(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ "Width is changed to adjust for aspect ration"
+ );
+ }
+
+ // Move so that part of PiP is off screen (bottom right)
+
+ left = rightEdge - Math.round((3 * pipWin.innerWidth) / 4);
+ top = bottomEdge - Math.round((3 * pipWin.innerHeight) / 4);
+
+ let movePromise = BrowserTestUtils.waitForEvent(
+ pipWin.windowRoot,
+ "MozUpdateWindowPos"
+ );
+ pipWin.moveTo(left, top);
+ await movePromise;
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Redefine top and left to where the PiP windop will open
+ left = rightEdge - pipWin.innerWidth;
+ top = bottomEdge - pipWin.innerHeight;
+
+ // await new Promise(r => setTimeout(r, 5000));
+ // PiP is opened bottom right but not off screen
+ checkIfEqual(
+ pipWin.screenX,
+ left,
+ "Opened at last X location but shifted back on screen"
+ );
+ if (AppConstants.platform == "linux") {
+ checkIfEqual(
+ pipWin.screenY,
+ top - tabHeight,
+ "Opened at last Y location but shifted back on screen"
+ );
+ } else {
+ checkIfEqual(
+ pipWin.screenY,
+ top,
+ "Opened at last Y location but shifted back on screen"
+ );
+ }
+
+ // Move so that part of PiP is off screen (top left)
+ left = defaultX - Math.round(pipWin.innerWidth / 4);
+ top = defaultY - Math.round(pipWin.innerHeight / 4);
+
+ movePromise = BrowserTestUtils.waitForEvent(
+ pipWin.windowRoot,
+ "MozUpdateWindowPos"
+ );
+ pipWin.moveTo(left, top);
+ await movePromise;
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // PiP is opened top left on screen
+ checkIfEqual(
+ pipWin.screenX,
+ defaultX,
+ "Opened at last X location but shifted back on screen"
+ );
+ checkIfEqual(
+ pipWin.screenY,
+ defaultY,
+ "Opened at last Y location but shifted back on screen"
+ );
+
+ if (AppConstants.platform != "linux") {
+ // test that if video is on right edge and new video with smaller width
+ // is opened next, it is still on the right edge
+ left = rightEdge - pipWin.innerWidth;
+ top = Math.round(bottomEdge / 4);
+
+ pipWin.moveTo(left, top);
+
+ // Used to ensure that video width decreases for next PiP window
+ width = pipWin.innerWidth;
+ checkIfEqual(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ "Video is on right edge before video is changed"
+ );
+
+ // Now switch the video so the video width is smaller
+ await switchVideoSource("test-video-cropped.mp4");
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ pipWin = await triggerPictureInPicture(browser, "with-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ checkIfEqual(
+ pipWin.innerWidth < width,
+ true,
+ "New video width is smaller"
+ );
+ checkIfEqual(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ "Video is on right edge after video is changed"
+ );
+ }
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js
new file mode 100644
index 0000000000..7d40664df4
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality of arrow keys in Picture-in-Picture window
+ * for seeking and volume adjustment
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.keyboard-controls.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let waitForVideoEvent = eventType => {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+ };
+
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // run the next tests 4 times to ensure that they work for each PiP button, including none
+ for (var i = 0; i < 4; i++) {
+ // Try seek forward
+ let seekedForwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ ok(await seekedForwardPromise, "The time seeked forward");
+
+ // Try seek backward
+ let seekedBackwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+ ok(await seekedBackwardPromise, "The time seeked backward");
+
+ // Try volume down
+ let volumeDownPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, pipWin);
+ ok(await volumeDownPromise, "The volume went down");
+
+ // Try volume up
+ let volumeUpPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+ ok(await volumeUpPromise, "The volume went up");
+
+ // Tab to get to the next button
+ EventUtils.synthesizeKey("KEY_Tab", {}, pipWin);
+ }
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_showMessage.js b/toolkit/components/pictureinpicture/tests/browser_showMessage.js
new file mode 100644
index 0000000000..24d8347a7f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_showMessage.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that triggering Picture-in-Picture causes the Picture-in-Picture
+ * window to be opened, and a message to be displayed in the original video
+ * player area. Also ensures that once the Picture-in-Picture window is closed,
+ * the video goes back to the original state.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js
new file mode 100644
index 0000000000..0c2a392d9f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is hidden when videos
+ * are laid out with dimensions smaller than MIN_VIDEO_DIMENSION (a
+ * constant that is also defined in videocontrols.js).
+ */
+add_task(async () => {
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ],
+ });
+
+ // This is the minimum size of the video in either width or height for
+ // which we will show the toggle. See videocontrols.js.
+ const MIN_VIDEO_DIMENSION = 140; // pixels
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ // Shrink the video down to less than MIN_VIDEO_DIMENSION.
+ let targetWidth = MIN_VIDEO_DIMENSION - 1;
+ await SpecialPowers.spawn(
+ browser,
+ [videoID, targetWidth],
+ async (videoID, targetWidth) => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = targetWidth + "px";
+ await resizePromise;
+ }
+ );
+
+ // The toggle should be hidden.
+ await testToggleHelper(browser, videoID, false);
+
+ // Now re-expand the video.
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = "";
+ await resizePromise;
+ });
+
+ // The toggle should be visible.
+ await testToggleHelper(browser, videoID, true);
+ }
+ );
+ }
+});
+
+/**
+ * Tests that when using the experimental toggle variations, videos
+ * under 320px width are given the "small-video" attribute.
+ */
+add_task(async () => {
+ const TOGGLE_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+ };
+
+ const TOGGLE_LARGE = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 0.0,
+ },
+ hidden: [".pip-explainer", ".pip-icon-label > .pip-icon"],
+ },
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [
+ ".pip-explainer",
+ ".pip-icon-label > .pip-icon",
+ ".pip-expanded",
+ ],
+ },
+ },
+ };
+
+ // Most of the Picture-in-Picture tests run with the always-show
+ // preference set to true to avoid the toggle visibility heuristics.
+ // Since this test actually exercises those heuristics, we have
+ // to temporarily disable that pref.
+ //
+ // We also reduce the minimum video length for displaying the toggle
+ // to 5 seconds to avoid having to include or generate a 45 second long
+ // video (which is the default minimum length).
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5],
+ ["media.videocontrols.picture-in-picture.video-toggle.mode", 1],
+ ],
+ });
+
+ // Videos that are thinner than MIN_VIDEO_WIDTH should have the small-video
+ // attribute set on the experimental toggle.
+ const MIN_VIDEO_WIDTH = 320; // pixels
+
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ // Shrink the video down to less than MIN_VIDEO_WIDTH.
+ let targetWidth = MIN_VIDEO_WIDTH - 1;
+ let isSmallVideo = await SpecialPowers.spawn(
+ browser,
+ [videoID, targetWidth],
+ async (videoID, targetWidth) => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = targetWidth + "px";
+ await resizePromise;
+ let toggle = shadowRoot.getElementById("pictureInPictureToggle");
+ return toggle.hasAttribute("small-video");
+ }
+ );
+
+ Assert.ok(isSmallVideo, "Video should have small-video attribute");
+
+ await testToggleHelper(browser, videoID, true, undefined, TOGGLE_SMALL);
+
+ // Now re-expand the video.
+ isSmallVideo = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let resizePromise = ContentTaskUtils.waitForEvent(
+ shadowRoot.firstChild,
+ "resizevideocontrols"
+ );
+ video.style.width = "";
+ await resizePromise;
+ let toggle = shadowRoot.getElementById("pictureInPictureToggle");
+ return toggle.hasAttribute("small-video");
+ }
+ );
+
+ Assert.ok(!isSmallVideo, "Video should not have small-video attribute");
+
+ await testToggleHelper(browser, videoID, true, undefined, TOGGLE_LARGE);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js
new file mode 100644
index 0000000000..10eb8f43ea
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that <video>'s with styles on the element don't have those
+ * styles cloned over into the <video> that's inserted into the
+ * player window.
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let styles = {
+ padding: "15px",
+ border: "5px solid red",
+ margin: "3px",
+ position: "absolute",
+ };
+
+ await SpecialPowers.spawn(browser, [styles], async videoStyles => {
+ let video = content.document.getElementById("no-controls");
+ for (let styleProperty in videoStyles) {
+ video.style[styleProperty] = videoStyles[styleProperty];
+ }
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let playerBrowser = pipWin.document.getElementById("browser");
+ await SpecialPowers.spawn(playerBrowser, [styles], async videoStyles => {
+ let video = content.document.querySelector("video");
+ for (let styleProperty in videoStyles) {
+ Assert.equal(
+ video.style[styleProperty],
+ "",
+ `Style ${styleProperty} should not be set`
+ );
+ }
+ });
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js
new file mode 100644
index 0000000000..3ca55f2c73
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * The goal of this test is check the that "tab-icon-overlay" image is
+ * showing when the tab is using PiP.
+ *
+ * The browser will create a tab and open a video using PiP
+ * then the tests check that the tab icon overlay image is showing*
+ *
+ *
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_SOUND,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let audioPromise = BrowserTestUtils.waitForEvent(
+ browser,
+ "DOMAudioPlaybackStarted"
+ );
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ // Check that video is playing
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+ await audioPromise;
+
+ // Need tab to access the tab-icon-overlay element
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // Use tab to get the tab-icon-overlay element
+ let tabIconOverlay = tab.getElementsByClassName("tab-icon-overlay")[0];
+
+ // Not in PiP yet so the tab-icon-overlay does not have "pictureinpicture" attribute
+ ok(!tabIconOverlay.hasAttribute("pictureinpicture"), "Not using PiP");
+
+ // Sound is playing so tab should have "soundplaying" attribute
+ ok(tabIconOverlay.hasAttribute("soundplaying"), "Sound is playing");
+
+ // Start the PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Check that video is still playing
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Video is still playing so the tab-icon-overlay should have "soundplaying" as an attribute
+ ok(
+ tabIconOverlay.hasAttribute("soundplaying"),
+ "Tab knows sound is playing"
+ );
+
+ // Now in PiP. "pictureinpicture" is an attribute
+ ok(
+ tabIconOverlay.hasAttribute("pictureinpicture"),
+ "Tab knows were using PiP"
+ );
+
+ // We know the tab has sound playing and it is using PiP so we can check the
+ // tab-icon-overlay image is showing
+ let style = window.getComputedStyle(tabIconOverlay);
+ Assert.equal(
+ style.listStyleImage,
+ 'url("chrome://browser/skin/tabbrowser/tab-audio-playing-small.svg")',
+ "Got the tab-icon-overlay image"
+ );
+
+ // Check tab is not muted
+ ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted");
+
+ // Click on tab icon overlay to mute tab and check it is muted
+ tabIconOverlay.click();
+ ok(tabIconOverlay.hasAttribute("muted"), "Tab is muted");
+
+ // Click on tab icon overlay to unmute tab and check it is not muted
+ tabIconOverlay.click();
+ ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
new file mode 100644
index 0000000000..cf63768d85
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that videos hosted inside of a third-party <iframe> can be opened
+ * in a Picture-in-Picture window.
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_IFRAME,
+ gBrowser,
+ },
+ async browser => {
+ // TEST_PAGE_WITH_IFRAME is hosted at a different domain from TEST_PAGE,
+ // so loading TEST_PAGE within the iframe will act as our third-party
+ // iframe.
+ await SpecialPowers.spawn(browser, [TEST_PAGE], async TEST_PAGE => {
+ let iframe = content.document.getElementById("iframe");
+ let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load");
+ iframe.src = TEST_PAGE;
+ await loadPromise;
+ });
+
+ let iframeBc = browser.browsingContext.children[0];
+
+ if (gFissionBrowser) {
+ Assert.notEqual(
+ browser.browsingContext.currentWindowGlobal.osPid,
+ iframeBc.currentWindowGlobal.osPid,
+ "The iframe should be running in a different process."
+ );
+ }
+
+ let pipWin = await triggerPictureInPicture(iframeBc, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(iframeBc, videoID, pipWin, true);
+ }
+ );
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js
new file mode 100644
index 0000000000..5588b3a775
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Helper function that tries to use the mouse to open the Picture-in-Picture
+ * player window for a video with and without the built-in controls.
+ *
+ * @param {Element} tab The tab to be tested.
+ * @return Promise
+ * @resolves When the toggles for both the video-with-controls and
+ * video-without-controls have been tested.
+ */
+async function testToggleForTab(tab) {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let browser = tab.linkedBrowser;
+ info(`Testing ${videoID} case.`);
+
+ await testToggleHelper(browser, videoID, true);
+ }
+}
+
+/**
+ * Tests that the Picture-in-Picture toggle still works after tearing out the
+ * tab into a new window, or tearing in a tab from one window to another.
+ */
+add_task(async () => {
+ // The startingTab will be torn out and placed in the new window.
+ let startingTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+
+ // Tear out the starting tab into its own window...
+ let newWinLoaded = BrowserTestUtils.waitForNewWindow();
+ let win2 = gBrowser.replaceTabWithWindow(startingTab);
+ await newWinLoaded;
+
+ // Let's maximize the newly opened window so we don't have to worry about
+ // the videos being visible.
+ if (win2.windowState != win2.STATE_MAXIMIZED) {
+ let resizePromise = BrowserTestUtils.waitForEvent(win2, "resize");
+ win2.maximize();
+ await resizePromise;
+ }
+
+ await SimpleTest.promiseFocus(win2);
+ await testToggleForTab(win2.gBrowser.selectedTab);
+
+ // Now bring the tab back to the original window.
+ let dragInTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ gBrowser.swapBrowsersAndCloseOther(dragInTab, win2.gBrowser.selectedTab);
+ await SimpleTest.promiseFocus(window);
+ await testToggleForTab(dragInTab);
+
+ BrowserTestUtils.removeTab(dragInTab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js
new file mode 100644
index 0000000000..6f81075770
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can be clicked when overlaid
+ * with a transparent button, but not clicked when overlaid with an
+ * opaque button.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-button-overlay.html";
+ await testToggle(PAGE, {
+ "video-partial-transparent-button": { canToggle: true },
+ "video-opaque-button": { canToggle: false },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
new file mode 100644
index 0000000000..e7a62f6278
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
@@ -0,0 +1,202 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * See the documentation for the DEFAULT_TOGGLE_STYLES object in head.js
+ * for a description of what these toggle style objects are representing.
+ */
+const TOGGLE_STYLES_LEFT_EXPLAINER = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-icon-label > .pip-icon"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-icon-label > .pip-icon"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_EXPLAINER = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 0.8,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-wrapper > .pip-icon"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": 1.0,
+ ".pip-expanded": 1.0,
+ },
+ hidden: [".pip-wrapper > .pip-icon"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_LEFT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+/**
+ * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the
+ * left and right positions, when the user is in the state where they've never
+ * clicked on the Picture-in-Picture toggle before (since we show a more detailed
+ * toggle in that state).
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "left"],
+ [HAS_USED_PREF, false],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await testToggle(TEST_PAGE, {
+ "no-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+
+ await testToggle(TEST_PAGE, {
+ "no-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER,
+ },
+ });
+
+ Assert.ok(
+ Services.prefs.getBoolPref(HAS_USED_PREF, false),
+ "Entered has-used mode."
+ );
+ Services.prefs.clearUserPref(HAS_USED_PREF);
+});
+
+/**
+ * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the
+ * left and right positions, when the user is in the state where they've
+ * used the Picture-in-Picture feature before.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.mode", 2],
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "left"],
+ [HAS_USED_PREF, true],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_SMALL,
+ },
+ "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL },
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": {
+ canToggle: true,
+ toggleStyles: TOGGLE_STYLES_LEFT_SMALL,
+ },
+ "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js
new file mode 100644
index 0000000000..7163966e64
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle correctly attaches itself when the
+ * video element has been inserted into the DOM after the video is ready to
+ * play.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-page.html";
+
+ await testToggle(
+ PAGE,
+ {
+ inserted: { canToggle: true },
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+
+ // To avoid issues with the video not being scrolled into view, get
+ // rid of the other videos on the page.
+ let preExistingVideos = doc.querySelectorAll("video");
+ for (let video of preExistingVideos) {
+ video.remove();
+ }
+
+ let newVideo = doc.createElement("video");
+ const { ContentTaskUtils } = ChromeUtils.import(
+ "resource://testing-common/ContentTaskUtils.jsm"
+ );
+ let ready = ContentTaskUtils.waitForEvent(newVideo, "canplay");
+ newVideo.src = "test-video.mp4";
+ newVideo.id = "inserted";
+ await ready;
+ doc.body.appendChild(newVideo);
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js
new file mode 100644
index 0000000000..18c906bf20
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is not clickable when
+ * overlaid with opaque elements.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-opaque-overlay.html";
+ await testToggle(PAGE, {
+ "video-full-opacity": { canToggle: false },
+ "video-full-opacity-over-toggle": { canToggle: false },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js
new file mode 100644
index 0000000000..23cc393960
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle is clickable even if the
+ * video element has pointer-events: none.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-pointer-events-none.html";
+ await testToggle(PAGE, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
new file mode 100644
index 0000000000..165fa1c72b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that by setting a Picture-in-Picture toggle position policy
+ * in the sharedData structure, that the toggle position can be
+ * change for a particular URI.
+ */
+add_task(async () => {
+ let positionPolicies = [
+ TOGGLE_POLICIES.TOP,
+ TOGGLE_POLICIES.ONE_QUARTER,
+ TOGGLE_POLICIES.THREE_QUARTERS,
+ TOGGLE_POLICIES.BOTTOM,
+ ];
+
+ for (let policy of positionPolicies) {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { policy },
+ });
+ Services.ppmm.sharedData.flush();
+
+ let expectations = {
+ "with-controls": { canToggle: true, policy },
+ "no-controls": { canToggle: true, policy },
+ };
+
+ // For <video> elements with controls, the video controls overlap the
+ // toggle when its on the bottom and can't be clicked, so we'll ignore
+ // that case.
+ if (policy == TOGGLE_POLICIES.BOTTOM) {
+ expectations["with-controls"] = { canToggle: true };
+ }
+
+ await testToggle(TEST_PAGE, expectations);
+
+ // And ensure that other pages aren't affected by this override.
+ await testToggle(TEST_PAGE_2, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+ }
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
+
+/**
+ * Tests that by setting a Picture-in-Picture toggle hidden policy
+ * in the sharedData structure, that the toggle can be suppressed.
+ */
+add_task(async () => {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { policy: TOGGLE_POLICIES.HIDDEN },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ "no-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ });
+
+ // And ensure that other pages aren't affected by this override.
+ await testToggle(TEST_PAGE_2, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
+
+/**
+ * Tests that policies are re-evaluated if the page URI is transitioned
+ * via the history API.
+ */
+add_task(async () => {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*/test-page.html": { policy: TOGGLE_POLICIES.HIDDEN },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ await SimpleTest.promiseFocus(browser);
+
+ await testToggleHelper(
+ browser,
+ "no-controls",
+ false,
+ TOGGLE_POLICIES.HIDDEN
+ );
+
+ await SpecialPowers.spawn(browser, [], async function() {
+ content.history.pushState({}, "2", "otherpage.html");
+ });
+
+ // Since we no longer match the policy URI, we should be able
+ // to use the Picture-in-Picture toggle.
+ await testToggleHelper(browser, "no-controls", true);
+
+ // Now use the history API to put us back at the original location,
+ // which should have the HIDDEN policy re-applied.
+ await SpecialPowers.spawn(browser, [], async function() {
+ content.history.pushState({}, "Return", "test-page.html");
+ });
+
+ await testToggleHelper(
+ browser,
+ "no-controls",
+ false,
+ TOGGLE_POLICIES.HIDDEN
+ );
+ }
+ );
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js
new file mode 100644
index 0000000000..a9351816fa
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that we show the Picture-in-Picture toggle on video
+ * elements when hovering them with the mouse cursor, and that
+ * clicking on them causes the Picture-in-Picture window to
+ * open if the toggle isn't being occluded. This test tests videos
+ * both with and without controls.
+ */
+add_task(async () => {
+ await testToggle(TEST_PAGE, {
+ "with-controls": { canToggle: true },
+ "no-controls": { canToggle: true },
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js
new file mode 100644
index 0000000000..658bb0f362
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can appear and be clicked
+ * when the video is overlaid with transparent elements. Also tests the
+ * site-specific toggle visibility threshold to ensure that we can
+ * configure opacities that can't be clicked through.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-transparent-overlay-1.html";
+ await testToggle(PAGE, {
+ "video-transparent-background": { canToggle: true },
+ "video-alpha-background": { canToggle: true },
+ });
+
+ // Now set a toggle visibility threshold to 0.4 and ensure that the
+ // partially obscured toggle can't be clicked.
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { visibilityThreshold: 0.4 },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(PAGE, {
+ "video-transparent-background": { canToggle: true },
+ "video-alpha-background": { canToggle: false },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js
new file mode 100644
index 0000000000..b425b50d1c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the Picture-in-Picture toggle can appear and be clicked
+ * when the video is overlaid with elements that have zero and partial
+ * opacity. Also tests the site-specific toggle visibility threshold to
+ * ensure that we can configure opacities that can't be clicked through.
+ */
+add_task(async () => {
+ const PAGE = TEST_ROOT + "test-transparent-overlay-2.html";
+ await testToggle(PAGE, {
+ "video-zero-opacity": { canToggle: true },
+ "video-partial-opacity": { canToggle: true },
+ });
+
+ // Now set a toggle visibility threshold to 0.4 and ensure that the
+ // partially obscured toggle can't be clicked.
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { visibilityThreshold: 0.4 },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await testToggle(PAGE, {
+ "video-zero-opacity": { canToggle: true },
+ "video-partial-opacity": { canToggle: false },
+ });
+
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_videoSelection.js b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js
new file mode 100644
index 0000000000..f887f158c1
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct video is opened in the
+ * Picture-in-Picture player when opened via keyboard shortcut.
+ * The shortcut will open the first unpaused video
+ * or the longest video on the page.
+ */
+add_task(async function test_video_selection() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_ROOT + "test-video-selection.html",
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let pipVideoID = await SpecialPowers.spawn(browser, [], () => {
+ let videoList = content.document.querySelectorAll("video");
+ let longestDuration = -1;
+ let pipVideoID = null;
+
+ for (let video of videoList) {
+ if (!video.paused) {
+ pipVideoID = video.id;
+ break;
+ }
+ if (video.duration > longestDuration) {
+ pipVideoID = video.id;
+ longestDuration = video.duration;
+ }
+ }
+ return pipVideoID;
+ });
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let videoReady = SpecialPowers.spawn(
+ browser,
+ [pipVideoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ }
+ );
+
+ let eventObj = { accelKey: true, shiftKey: true };
+ if (AppConstants.platform == "macosx") {
+ eventObj.altKey = true;
+ }
+ EventUtils.synthesizeKey("]", eventObj, window);
+
+ let pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false);
+
+ pipVideoID = await SpecialPowers.spawn(browser, [], () => {
+ let videoList = content.document.querySelectorAll("video");
+ videoList[1].play();
+ videoList[2].play();
+ let longestDuration = -1;
+ let pipVideoID = null;
+
+ for (let video of videoList) {
+ if (!video.paused) {
+ pipVideoID = video.id;
+ break;
+ }
+ if (video.duration > longestDuration) {
+ pipVideoID = video.id;
+ longestDuration = video.duration;
+ }
+ }
+
+ return pipVideoID;
+ });
+
+ // Next time we want to use a keyboard shortcut with the main window in focus again.
+ await SimpleTest.promiseFocus(browser);
+
+ domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ videoReady = SpecialPowers.spawn(browser, [pipVideoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+
+ EventUtils.synthesizeKey("]", eventObj, window);
+
+ pipWin = await domWindowOpened;
+ await videoReady;
+
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/click-event-helper.js b/toolkit/components/pictureinpicture/tests/click-event-helper.js
new file mode 100644
index 0000000000..6b3ba42994
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/click-event-helper.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This helper script is used to record mouse button events for
+ * Picture-in-Picture toggle click tests. Anytime the toggle is
+ * clicked, we expect none of the events to be fired. Otherwise,
+ * all events should be fired when clicking.
+ */
+
+let eventTypes = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"];
+
+for (let event of eventTypes) {
+ addEventListener(event, recordEvent, { capture: true });
+}
+
+let recordedEvents = [];
+function recordEvent(event) {
+ recordedEvents.push(event.type);
+}
+
+function getRecordedEvents() {
+ let result = recordedEvents.concat();
+ recordedEvents = [];
+ return result;
+}
diff --git a/toolkit/components/pictureinpicture/tests/head.js b/toolkit/components/pictureinpicture/tests/head.js
new file mode 100644
index 0000000000..8df9062263
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/head.js
@@ -0,0 +1,835 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TOGGLE_POLICIES } = ChromeUtils.import(
+ "resource://gre/modules/PictureInPictureControls.jsm"
+);
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.org"
+);
+const TEST_PAGE = TEST_ROOT + "test-page.html";
+const TEST_PAGE_2 = TEST_ROOT_2 + "test-page.html";
+const TEST_PAGE_WITH_IFRAME = TEST_ROOT_2 + "test-page-with-iframe.html";
+const TEST_PAGE_WITH_SOUND = TEST_ROOT + "test-page-with-sound.html";
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+const HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides";
+
+/**
+ * We currently ship with a few different variations of the
+ * Picture-in-Picture toggle. The tests for Picture-in-Picture include tests
+ * that check the style rules of various parts of the toggle. Since each toggle
+ * variation has different style rules, we introduce a structure here to
+ * describe the appearance of the toggle at different stages for the tests.
+ *
+ * The top-level structure looks like this:
+ *
+ * {
+ * rootID (String): The ID of the root element of the toggle.
+ * stages (Object): An Object representing the styles of the toggle at
+ * different stages of its use. Each property represents a different
+ * stage that can be tested. Right now, those stages are:
+ *
+ * hoverVideo:
+ * When the mouse is hovering the video but not the toggle.
+ *
+ * hoverToggle:
+ * When the mouse is hovering both the video and the toggle.
+ *
+ * Both stages must be assigned an Object with the following properties:
+ *
+ * opacities:
+ * This should be set to an Object where the key is a CSS selector for
+ * an element, and the value is a double for what the eventual opacity
+ * of that element should be set to.
+ *
+ * hidden:
+ * This should be set to an Array of CSS selector strings for elements
+ * that should be hidden during a particular stage.
+ * }
+ *
+ * DEFAULT_TOGGLE_STYLES is the set of styles for the default variation of the
+ * toggle.
+ */
+const DEFAULT_TOGGLE_STYLES = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": 0.8,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+/**
+ * Given a browser and the ID for a <video> element, triggers
+ * Picture-in-Picture for that <video>, and resolves with the
+ * Picture-in-Picture window once it is ready to be used.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @return Promise
+ * @resolves With the Picture-in-Picture window when ready.
+ */
+async function triggerPictureInPicture(browser, videoID) {
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let event = new content.CustomEvent("MozTogglePictureInPicture", {
+ bubbles: true,
+ });
+ video.dispatchEvent(event);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ let win = await domWindowOpened;
+ await Promise.all([
+ SimpleTest.promiseFocus(win),
+ win.promiseDocumentFlushed(() => {}),
+ videoReady,
+ ]);
+ return win;
+}
+
+/**
+ * Given a browser and the ID for a <video> element, checks that the
+ * video is showing the "This video is playing in Picture-in-Picture mode."
+ * status message overlay.
+ *
+ * @param {Element,BrowsingContext} browser The <xul:browser> or
+ * BrowsingContext hosting the <video>
+ *
+ * @param {String} videoID The ID of the video to trigger
+ * Picture-in-Picture on.
+ *
+ * @param {bool} expected True if we expect the message to be showing.
+ *
+ * @return Promise
+ * @resolves When the checks have completed.
+ */
+async function assertShowingMessage(browser, videoID, expected) {
+ let showing = await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipOverlay = shadowRoot.querySelector(".pictureInPictureOverlay");
+ Assert.ok(pipOverlay, "Should be able to find Picture-in-Picture overlay.");
+
+ let rect = pipOverlay.getBoundingClientRect();
+ return rect.height > 0 && rect.width > 0;
+ });
+ Assert.equal(
+ showing,
+ expected,
+ "Video should be showing the expected state."
+ );
+}
+
+/**
+ * Tests if a video is currently being cloned for a given content browser. Provides a
+ * good indicator for answering if this video is currently open in PiP.
+ *
+ * @param {Browser} browser
+ * The content browser that the video lives in
+ * @param {string} videoId
+ * The id associated with the video
+ *
+ * @returns {bool}
+ * Whether the video is currently being cloned (And is most likely open in PiP)
+ */
+function assertVideoIsBeingCloned(browser, videoId) {
+ return SpecialPowers.spawn(browser, [videoId], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+}
+
+/**
+ * Ensures that each of the videos loaded inside of a document in a
+ * <browser> have reached the HAVE_ENOUGH_DATA readyState.
+ *
+ * @param {Element} browser The <xul:browser> hosting the <video>(s)
+ *
+ * @return Promise
+ * @resolves When each <video> is in the HAVE_ENOUGH_DATA readyState.
+ */
+async function ensureVideosReady(browser) {
+ // PictureInPictureToggleChild waits for videos to fire their "canplay"
+ // event before considering them for the toggle, so we start by making
+ // sure each <video> has done this.
+ info(`Waiting for videos to be ready`);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let videos = this.content.document.querySelectorAll("video");
+ for (let video of videos) {
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for '${video.id}'`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ }
+ });
+}
+
+/**
+ * Tests that the toggle opacity reaches or exceeds a certain threshold within
+ * a reasonable time.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {String} stage The stage for which the opacity is going to change. This
+ * should be one of "hoverVideo" or "hoverToggle".
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ stage,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let togglePosition = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ "right"
+ );
+ let hasUsed = Services.prefs.getBoolPref(HAS_USED_PREF, false);
+ let toggleStylesForStage = toggleStyles.stages[stage];
+ info(
+ `Testing toggle for stage ${stage} ` +
+ `in position ${togglePosition}, has used: ${hasUsed}`
+ );
+
+ let args = { videoID, toggleStylesForStage, togglePosition, hasUsed };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleStylesForStage } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ for (let hiddenElement of toggleStylesForStage.hidden) {
+ let el = shadowRoot.querySelector(hiddenElement);
+ ok(
+ ContentTaskUtils.is_hidden(el),
+ `Expected ${hiddenElement} to be hidden.`
+ );
+ }
+
+ for (let opacityElement in toggleStylesForStage.opacities) {
+ let opacityThreshold = toggleStylesForStage.opacities[opacityElement];
+ let el = shadowRoot.querySelector(opacityElement);
+
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ let opacity = parseFloat(this.content.getComputedStyle(el).opacity);
+ return opacity >= opacityThreshold;
+ },
+ `Toggle element ${opacityElement} should have eventually reached ` +
+ `target opacity ${opacityThreshold}`,
+ 100,
+ 100
+ );
+ }
+
+ ok(true, "Toggle reached target opacity.");
+ });
+}
+
+/**
+ * Tests that the toggle has the correct policy attribute set. This should be called
+ * either when the toggle is visible, or events have been queued such that the toggle
+ * will soon be visible.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> in it.
+ * @param {String} videoID The ID of the video element that we expect the toggle
+ * to appear on.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.jsm.
+ * If undefined, this function will ensure no policy attribute is set.
+ *
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertTogglePolicy(
+ browser,
+ videoID,
+ policy,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let toggleID = toggleStyles.rootID;
+ let args = { videoID, toggleID, policy };
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, toggleID, policy } = args;
+
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ let toggle = shadowRoot.getElementById(toggleID);
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return controlsOverlay.classList.contains("hovering");
+ }, "Waiting for the hovering state to be set on the video.");
+
+ if (policy) {
+ const { TOGGLE_POLICY_STRINGS } = ChromeUtils.import(
+ "resource://gre/modules/PictureInPictureControls.jsm"
+ );
+ let policyAttr = toggle.getAttribute("policy");
+ Assert.equal(
+ policyAttr,
+ TOGGLE_POLICY_STRINGS[policy],
+ "The correct toggle policy is set."
+ );
+ } else {
+ Assert.ok(
+ !toggle.hasAttribute("policy"),
+ "No toggle policy should be set."
+ );
+ }
+ });
+}
+
+/**
+ * Tests that either all or none of the expected mousebutton events
+ * fire in web content when clicking on the page.
+ *
+ * Note: This function will only work on pages that load the
+ * click-event-helper.js script.
+ *
+ * @param {Element} browser The <xul:browser> that will receive the mouse
+ * events.
+ * @param {bool} isExpectingEvents True if we expect all of the normal
+ * mouse button events to fire. False if we expect none of them to fire.
+ * @param {bool} isExpectingClick True if the mouse events should include the
+ * "click" event, which is only included when the primary mouse button is pressed.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawMouseEvents(
+ browser,
+ isExpectingEvents,
+ isExpectingClick = true
+) {
+ const MOUSE_BUTTON_EVENTS = [
+ "pointerdown",
+ "mousedown",
+ "pointerup",
+ "mouseup",
+ ];
+
+ if (isExpectingClick) {
+ MOUSE_BUTTON_EVENTS.push("click");
+ }
+
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+
+ let expectedEvents = isExpectingEvents ? MOUSE_BUTTON_EVENTS : [];
+ Assert.deepEqual(
+ mouseEvents,
+ expectedEvents,
+ "Expected to get the right mouse events."
+ );
+}
+
+/**
+ * Ensures that a <video> inside of a <browser> is scrolled into view,
+ * and then returns the coordinates of its Picture-in-Picture toggle as well
+ * as whether or not the <video> element is showing the built-in controls.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * controls: <Boolean>,
+ * }
+ *
+ * Where controls represents whether or not the video has the default control set
+ * displayed.
+ */
+async function prepareForToggleClick(browser, videoID) {
+ // Synthesize a mouse move just outside of the video to ensure that
+ // the video is in a non-hovering state. We'll go 5 pixels to the
+ // left and above the top-left corner.
+ await BrowserTestUtils.synthesizeMouse(
+ `#${videoID}`,
+ -5,
+ -5,
+ {
+ type: "mousemove",
+ },
+ browser,
+ false
+ );
+
+ // For each video, make sure it's scrolled into view, and get the rect for
+ // the toggle while we're at it.
+ let args = { videoID };
+ return SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID } = args;
+
+ let video = content.document.getElementById(videoID);
+ video.scrollIntoView({ behaviour: "instant" });
+
+ if (!video.controls) {
+ // For no-controls <video> elements, an IntersectionObserver is used
+ // to know when we the PictureInPictureChild should begin tracking
+ // mousemove events. We don't exactly know when that IntersectionObserver
+ // will fire, so we poll a special testing function that will tell us when
+ // the video that we care about is being tracked.
+ let { PictureInPictureToggleChild } = ChromeUtils.import(
+ "resource://gre/actors/PictureInPictureChild.jsm"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return PictureInPictureToggleChild.isTracking(video);
+ },
+ "Waiting for PictureInPictureToggleChild to be tracking the video.",
+ 100,
+ 100
+ );
+ }
+
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ await ContentTaskUtils.waitForCondition(
+ () => {
+ return !controlsOverlay.classList.contains("hovering");
+ },
+ "Waiting for the video to not be hovered.",
+ 100,
+ 100
+ );
+
+ return {
+ controls: video.controls,
+ };
+ });
+}
+
+/**
+ * Returns client rect info for the toggle if it's supposed to be visible
+ * on hover. Otherwise, returns client rect info for the video with the
+ * associated ID.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ *
+ * @return Promise
+ * @resolves With the following Object structure:
+ * {
+ * top: <Number>,
+ * left: <Number>,
+ * width: <Number>,
+ * height: <Number>,
+ * }
+ */
+async function getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles = DEFAULT_TOGGLE_STYLES
+) {
+ let args = { videoID, toggleID: toggleStyles.rootID };
+ return ContentTask.spawn(browser, args, async args => {
+ const { Rect } = ChromeUtils.import("resource://gre/modules/Geometry.jsm");
+
+ let { videoID, toggleID } = args;
+ let video = content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let toggle = shadowRoot.getElementById(toggleID);
+ let rect = Rect.fromRect(toggle.getBoundingClientRect());
+
+ let clickableChildren = toggle.querySelectorAll(".clickable");
+ for (let child of clickableChildren) {
+ let childRect = Rect.fromRect(child.getBoundingClientRect());
+ rect.expandToContain(childRect);
+ }
+
+ if (!rect.width && !rect.height) {
+ rect = video.getBoundingClientRect();
+ }
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ });
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Loads a page, and then
+ * tests the provided video elements for the toggle both appearing and
+ * opening the Picture-in-Picture window in the expected cases.
+ *
+ * @param {String} testURL The URL of the page with the <video> elements.
+ * @param {Object} expectations An object with the following schema:
+ * <video-element-id>: {
+ * canToggle: {Boolean}
+ * policy: {Number} (optional)
+ * styleRules: {Object} (optional)
+ * }
+ * If canToggle is true, then it's expected that moving the mouse over the
+ * video and then clicking in the toggle region should open a
+ * Picture-in-Picture window. If canToggle is false, we expect that a click
+ * in this region will not result in the window opening.
+ *
+ * If policy is defined, then it should be one of the values in the
+ * TOGGLE_POLICIES from PictureInPictureControls.jsm.
+ *
+ * See the documentation for the DEFAULT_TOGGLE_STYLES object for a sense
+ * of what styleRules is expected to be. If left undefined, styleRules will
+ * default to DEFAULT_TOGGLE_STYLES.
+ *
+ * @param {async Function} prepFn An optional asynchronous function to run
+ * before running the toggle test. The function is passed the opened
+ * <xul:browser> as its only argument once the testURL has finished loading.
+ *
+ * @return Promise
+ * @resolves When the test is complete and the tab with the loaded page is
+ * removed.
+ */
+async function testToggle(testURL, expectations, prepFn = async () => {}) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: testURL,
+ },
+ async browser => {
+ await prepFn(browser);
+ await ensureVideosReady(browser);
+
+ for (let [videoID, { canToggle, policy, toggleStyles }] of Object.entries(
+ expectations
+ )) {
+ await SimpleTest.promiseFocus(browser);
+ info(`Testing video with id: ${videoID}`);
+
+ await testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Test helper for the Picture-in-Picture toggle. Given a loaded page with some
+ * videos on it, tests that the toggle behaves as expected when interacted
+ * with by the mouse.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Boolean} canToggle True if we expect the toggle to be visible and
+ * clickable by the mouse for the associated video.
+ * @param {Number} policy Optional argument. If policy is defined, then it should
+ * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.jsm.
+ * @param {Object} toggleStyles Optional argument. See the documentation for the
+ * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be.
+ *
+ * @return Promise
+ * @resolves When the check for the toggle is complete.
+ */
+async function testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles
+) {
+ let { controls } = await prepareForToggleClick(browser, videoID);
+
+ // Hover the mouse over the video to reveal the toggle.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info("Checking toggle policy");
+ await assertTogglePolicy(browser, videoID, policy, toggleStyles);
+
+ if (canToggle) {
+ info("Waiting for toggle to become visible");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverVideo",
+ toggleStyles
+ );
+ }
+
+ let toggleClientRect = await getToggleClientRect(
+ browser,
+ videoID,
+ toggleStyles
+ );
+
+ info("Hovering the toggle rect now.");
+ let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2;
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mousemove",
+ },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ if (canToggle) {
+ info("Waiting for toggle to reach full opacity");
+ await toggleOpacityReachesThreshold(
+ browser,
+ videoID,
+ "hoverToggle",
+ toggleStyles
+ );
+ }
+
+ // First, ensure that a non-primary mouse click is ignored.
+ info("Right-clicking on toggle.");
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ { button: 2 },
+ browser
+ );
+
+ // For videos without the built-in controls, we expect that all mouse events
+ // should have fired - otherwise, the events are all suppressed. For videos
+ // with controls, none of the events should be fired, as the controls overlay
+ // absorbs them all.
+ //
+ // Note that the right-click does not result in a "click" event firing.
+ await assertSawMouseEvents(browser, !controls, false);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+
+ // Okay, now test with the primary mouse button.
+
+ if (canToggle) {
+ info(
+ "Clicking on toggle, and expecting a Picture-in-Picture window to open"
+ );
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, videoID);
+
+ await BrowserTestUtils.closeWindow(win);
+
+ // Make sure that clicking on the toggle resulted in no mouse button events
+ // being fired in content.
+ await assertSawMouseEvents(browser, false);
+ } else {
+ info(
+ "Clicking on toggle, and expecting no Picture-in-Picture window opens"
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ toggleCenterX,
+ toggleCenterY,
+ {},
+ browser
+ );
+
+ // If we aren't showing the toggle, we expect all mouse events to be seen.
+ await assertSawMouseEvents(browser, !controls);
+
+ // The message to open the Picture-in-Picture window would normally be sent
+ // immediately before this Promise resolved, so the window should have opened
+ // by now if it was going to happen.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(false, "Found a Picture-in-Picture window unexpectedly.");
+ return;
+ }
+ }
+
+ ok(true, "No Picture-in-Picture window found.");
+ }
+
+ // Click on the very top-left pixel of the document and ensure that we
+ // see all of the mouse events for it.
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully enter fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to enter fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen entering transition completes.
+ */
+async function promiseFullscreenEntered(window, asyncFn) {
+ let entered = BrowserTestUtils.waitForEvent(
+ window,
+ "MozDOMFullscreen:Entered"
+ );
+
+ await asyncFn();
+
+ await entered;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+}
+
+/**
+ * Helper function that ensures that a provided async function
+ * causes a window to fully exit fullscreen mode.
+ *
+ * @param window (DOM Window)
+ * The window that is expected to exit fullscreen mode.
+ * @param asyncFn (Async Function)
+ * The async function to run to trigger the fullscreen switch.
+ * @return Promise
+ * @resolves When the fullscreen exiting transition completes.
+ */
+async function promiseFullscreenExited(window, asyncFn) {
+ let exited = BrowserTestUtils.waitForEvent(window, "MozDOMFullscreen:Exited");
+
+ await asyncFn();
+
+ await exited;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS");
+ });
+
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the fullscreen transition takes some extra time
+ // to complete, and we don't receive events for it. We need to
+ // wait for it to complete or else input events in the next test
+ // might get eaten up. This is the best we can currently do.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * Helper function that ensures that the "This video is
+ * playing in Picture-in-Picture mode" message works,
+ * then closes the player window
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video that has the toggle.
+ * @param {Element} pipWin The Picture-in-Picture window that was opened
+ * @param {Boolean} iframe True if the test is on an Iframe, which modifies
+ * the test behavior
+ */
+async function ensureMessageAndClosePiP(browser, videoID, pipWin, isIframe) {
+ try {
+ await assertShowingMessage(browser, videoID, true);
+ } finally {
+ let uaWidgetUpdate = null;
+ if (isIframe) {
+ uaWidgetUpdate = SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForEvent(
+ content.windowRoot,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ });
+ } else {
+ uaWidgetUpdate = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "UAWidgetSetupOrChange",
+ true /* capture */
+ );
+ }
+ await BrowserTestUtils.closeWindow(pipWin);
+ await uaWidgetUpdate;
+ }
+}
+
+/**
+ * Helper function that returns True if the specified video is paused
+ * and False if the specified video is not paused.
+ *
+ * @param {Element} browser The <xul:browser> that has the <video> loaded in it.
+ * @param {String} videoID The ID of the video to check.
+ */
+async function isVideoPaused(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).paused;
+ });
+}
diff --git a/toolkit/components/pictureinpicture/tests/short.mp4 b/toolkit/components/pictureinpicture/tests/short.mp4
new file mode 100644
index 0000000000..abe37b9f9d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/short.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-button-overlay.html b/toolkit/components/pictureinpicture/tests/test-button-overlay.html
new file mode 100644
index 0000000000..9917fba973
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-button-overlay.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .toggle-overlay {
+ position: absolute;
+ min-width: 50px;
+ right: 0px;
+ height: 100%;
+ top: calc(50% - 25px);
+ }
+
+ button {
+ height: 100px;
+ }
+
+ .transparent-background {
+ background-color: transparent;
+ }
+
+ .partial-opacity {
+ opacity: 0.5;
+ }
+
+ .full-opacity {
+ opacity: 1.0;
+ background-color: green;
+ }
+
+ .no-pointer-events {
+ pointer-events: none;
+ }
+
+ .pointer-events {
+ pointer-events: auto;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay transparent-background no-pointer-events">
+ This is a fully transparent overlay using a transparent background.
+ <div class="toggle-overlay partial-opacity pointer-events">
+ <button>I'm a button overlapping the toggle</button>
+ </div>
+ </div>
+ <video id="video-partial-transparent-button" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay transparent-background no-pointer-events">
+ This is a fully transparent overlay using a transparent background.
+ <div class="toggle-overlay full-opacity pointer-events">
+ <button>I'm a button overlapping the toggle</button>
+ </div>
+ </div>
+ <video id="video-opaque-button" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-media-stream.html b/toolkit/components/pictureinpicture/tests/test-media-stream.html
new file mode 100644
index 0000000000..ad5f91dd25
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-media-stream.html
@@ -0,0 +1,25 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = document.getElementById(videoID);
+ let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true });
+ video.dispatchEvent(event);
+ }
+ }
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html
new file mode 100644
index 0000000000..425c0e74c2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .toggle-overlay {
+ position: absolute;
+ min-width: 50px;
+ right: 0px;
+ height: 100%;
+ top: calc(50% - 25px);
+ }
+
+ .full-opacity {
+ opacity: 1.0;
+ background-color: green;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay full-opacity">This is a fully opaque overlay using opacity: 1.0</div>
+ <video id="video-full-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="toggle-overlay full-opacity">This is a fully opaque overlay over a region covering the toggle at opacity: 1.0</div>
+ <video id="video-full-opacity-over-toggle" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html
new file mode 100644
index 0000000000..02205a028b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html
@@ -0,0 +1,27 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ html, body {
+ height: 100vh;
+ width: 100vw;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ }
+ #iframe {
+ height: 100vh;
+ width: 100vw;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ }
+</style>
+<body>
+ <iframe id="iframe"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-sound.html b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html
new file mode 100644
index 0000000000..6b6a860bc2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests (longer video with sound)</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="gizmo.mp4" controls loop="true"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="gizmo.mp4" loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page.html b/toolkit/components/pictureinpicture/tests/test-page.html
new file mode 100644
index 0000000000..a62ff1ac4a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="test-video.mp4" loop="true" width="400" height="225"></video>
+
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ let video = document.getElementById(videoID);
+ let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true });
+ video.dispatchEvent(event);
+ }
+ }
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html
new file mode 100644
index 0000000000..63254b329c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - pointer-events: none</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ pointer-events: none;
+ }
+
+</style>
+<body>
+ <h1>Video with controls</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true"></video>
+ <h1>Video without controls</h1>
+ <video id="no-controls" src="test-video.mp4" loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-reversed.html b/toolkit/components/pictureinpicture/tests/test-reversed.html
new file mode 100644
index 0000000000..4f6d516698
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-reversed.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ #reversed {
+ transform: scaleX(-1);
+ }
+</style>
+<body>
+ <h1>Reversed video</h1>
+ <video id="reversed" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Not Reversed Video</h1>
+ <video id="not-reversed" src="test-video.mp4" loop="true" width="400" height="225"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html
new file mode 100644
index 0000000000..d7efcc1e92
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent iframe</title>
+</head>
+
+<style>
+ video {
+ display: block;
+ }
+
+ .root {
+ position: relative;
+ display: inline-block;
+ }
+
+ .controls {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .container,
+ iframe {
+ width: 100%;
+ height: 100%;
+ }
+
+ iframe {
+ border: 0;
+ }
+</style>
+
+<body>
+ <div class="root">
+ <div class="controls">
+ <div class="container">
+ <iframe src="about:blank"></iframe>
+ </div>
+ </div>
+
+ <div class="video-container">
+ <video id="video-transparent-background" src="test-video.mp4" loop="true"></video>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html
new file mode 100644
index 0000000000..8f0f76311b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .transparent-background {
+ background-color: transparent;
+ }
+
+ .alpha-background {
+ background-color: rgba(255, 0, 0, 0.5);
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay transparent-background">This is a fully transparent overlay</div>
+ <video id="video-transparent-background" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay alpha-background">This is a partially transparent overlay using alpha</div>
+ <video id="video-alpha-background" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html
new file mode 100644
index 0000000000..86dab15690
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture test - transparent overlays - 1</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ }
+
+ .container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .overlay {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ color: white;
+ }
+
+ .zero-opacity {
+ opacity: 0;
+ }
+
+ .partial-opacity {
+ opacity: 0.5;
+ }
+</style>
+<body>
+ <div class="container">
+ <div class="overlay zero-opacity">This is a transparent overlay using opacity: 0</div>
+ <video id="video-zero-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+
+ <div class="container">
+ <div class="overlay partial-opacity">This is a partially transparent overlay using opacity: 0.5</div>
+ <video id="video-partial-opacity" src="test-video.mp4" loop="true"></video>
+ </div>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4
new file mode 100644
index 0000000000..6ea66eb1fc
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video-long.mp4 b/toolkit/components/pictureinpicture/tests/test-video-long.mp4
new file mode 100644
index 0000000000..714c17ca12
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-long.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video-selection.html b/toolkit/components/pictureinpicture/tests/test-video-selection.html
new file mode 100644
index 0000000000..4ce65d6309
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-selection.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+ <script type="text/javascript"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Shortest Video</h1>
+ <video id="shortest" src="test-video.mp4" controls loop="true"></video>
+ <h1>Shorter Video</h1>
+ <video id="short" src="short.mp4" controls loop="true"></video>
+ <h1>Longer Video</h1>
+ <video id="long" src="test-video-long.mp4" controls loop="true"></video>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4
new file mode 100644
index 0000000000..404eec6fd0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4
Binary files differ
diff --git a/toolkit/components/pictureinpicture/tests/test-video.mp4 b/toolkit/components/pictureinpicture/tests/test-video.mp4
new file mode 100644
index 0000000000..90bbe6bc26
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-video.mp4
Binary files differ