summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/pictureinpicture')
-rw-r--r--toolkit/components/pictureinpicture/PictureInPicture.sys.mjs1647
-rw-r--r--toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs40
-rw-r--r--toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml46
-rw-r--r--toolkit/components/pictureinpicture/content/player.js1407
-rw-r--r--toolkit/components/pictureinpicture/content/player.xhtml119
-rw-r--r--toolkit/components/pictureinpicture/docs/PiP-diagram.svg4
-rw-r--r--toolkit/components/pictureinpicture/docs/index.rst385
-rw-r--r--toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst4
-rw-r--r--toolkit/components/pictureinpicture/docs/player-api.rst4
-rw-r--r--toolkit/components/pictureinpicture/jar.mn8
-rw-r--r--toolkit/components/pictureinpicture/moz.build21
-rw-r--r--toolkit/components/pictureinpicture/tests/browser.toml239
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js314
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js69
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_audioScrubber.js262
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_backgroundTab.js93
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js511
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePipPause.js68
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js113
-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_close_unpip_focus.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_conflictingPips.js46
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_contextMenu.js238
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_controlsHover.js191
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js271
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js101
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js47
-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_fontSize_change.js152
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_fullscreen.js161
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_improved_controls.js303
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js42
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js134
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js53
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js54
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js32
-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.js225
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js214
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js118
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js121
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js114
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js53
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js46
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_occluded_window.js258
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_playerControls.js86
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js167
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_privateWindow.js38
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js84
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_resizeVideo.js293
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_reversePiP.js145
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js398
-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_subtitles_settings_panel.js273
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js90
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js230
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js129
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js448
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js218
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js72
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js58
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js32
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js17
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js204
-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.js127
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js58
-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_toggle_enabled.js99
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js76
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js71
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js242
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js456
-rw-r--r--toolkit/components/pictureinpicture/tests/browser_videoEmptied.js155
-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.js1153
-rw-r--r--toolkit/components/pictureinpicture/tests/no-audio-track.webmbin0 -> 215529 bytes
-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-multiple-contexts.html19
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html18
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-iframe.html27
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html14
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-sound.html20
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html66
-rw-r--r--toolkit/components/pictureinpicture/tests/test-page-without-audio.html18
-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
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt10
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt10
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt11
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt15
-rw-r--r--toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt12
117 files changed, 15556 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
new file mode 100644
index 0000000000..2356548bf8
--- /dev/null
+++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
@@ -0,0 +1,1647 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { ShortcutUtils } from "resource://gre/modules/ShortcutUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ WindowsUIUtils: ["@mozilla.org/windows-ui-utils;1", "nsIWindowsUIUtils"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ASRouter:
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "resource:///modules/asrouter/ASRouter.sys.mjs",
+ PageActions: "resource:///modules/PageActions.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+import { Rect, Point } from "resource://gre/modules/Geometry.sys.mjs";
+
+const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
+// Currently, we need titlebar="yes" on macOS in order for the player window
+// to be resizable. See bug 1824171.
+const TITLEBAR = AppConstants.platform == "macosx" ? "yes" : "no";
+const PLAYER_FEATURES = `chrome,alwaysontop,lockaspectratio,resizable,dialog,titlebar=${TITLEBAR}`;
+
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const TOGGLE_FIRST_SEEN_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs";
+const TOGGLE_HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+const TOGGLE_POSITION_RIGHT = "right";
+const TOGGLE_POSITION_LEFT = "left";
+const RESIZE_MARGIN_PX = 16;
+const BACKGROUND_DURATION_HISTOGRAM_ID =
+ "FX_PICTURE_IN_PICTURE_BACKGROUND_TAB_PLAYING_DURATION";
+const FOREGROUND_DURATION_HISTOGRAM_ID =
+ "FX_PICTURE_IN_PICTURE_FOREGROUND_TAB_PLAYING_DURATION";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PIP_ENABLED",
+ "media.videocontrols.picture-in-picture.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PIP_URLBAR_BUTTON",
+ "media.videocontrols.picture-in-picture.urlbar-button.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "RESPECT_PIP_DISABLED",
+ "media.videocontrols.picture-in-picture.respect-disablePictureInPicture",
+ true
+);
+
+/**
+ * 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;
+
+export class PictureInPictureLauncherParent extends JSWindowActorParent {
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "PictureInPicture:Request": {
+ let videoData = aMessage.data;
+ PictureInPicture.handlePictureInPictureRequest(this.manager, videoData);
+ break;
+ }
+ }
+ }
+}
+
+export 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;
+ }
+ case "PictureInPicture:UpdateEligiblePipVideoCount": {
+ let { pipCount, pipDisabledCount } = aMessage.data;
+ PictureInPicture.updateEligiblePipVideoCount(browsingContext, {
+ pipCount,
+ pipDisabledCount,
+ });
+ PictureInPicture.updateUrlbarToggle(browser);
+ break;
+ }
+ case "PictureInPicture:SetFirstSeen": {
+ let { dateSeconds } = aMessage.data;
+ PictureInPicture.setFirstSeen(dateSeconds);
+ break;
+ }
+ case "PictureInPicture:SetHasUsed": {
+ let { hasUsed } = aMessage.data;
+ PictureInPicture.setHasUsed(hasUsed);
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This module is responsible for creating a Picture in Picture window to host
+ * a clone of a video element running in web content.
+ */
+export 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;
+ PictureInPicture.closeSinglePipWindow({ reason, actorRef: this });
+ 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;
+ }
+ case "PictureInPicture:EnableSubtitlesButton": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.enableSubtitlesButton();
+ }
+ break;
+ }
+ case "PictureInPicture:DisableSubtitlesButton": {
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ if (player) {
+ player.disableSubtitlesButton();
+ }
+ break;
+ }
+ case "PictureInPicture:SetTimestampAndScrubberPosition": {
+ let { timestamp, scrubberPosition } = aMessage.data;
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ player.setTimestamp(timestamp);
+ player.setScrubberPosition(scrubberPosition);
+ break;
+ }
+ case "PictureInPicture:VolumeChange": {
+ let { volume } = aMessage.data;
+ let player = PictureInPicture.getWeakPipPlayer(this);
+ player.setVolume(volume);
+ break;
+ }
+ }
+ }
+}
+
+/**
+ * This module is responsible for creating a Picture in Picture window to host
+ * a clone of a video element running in web content.
+ */
+export 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(),
+
+ // Maps a browser to the number of PiP windows it has
+ browserWeakMap: new WeakMap(),
+
+ // Maps an AppWindow to the number of PiP windows it has
+ originatingWinWeakMap: new WeakMap(),
+
+ // Maps a WindowGlobal to count of eligible PiP videos
+ weakGlobalToEligiblePipCount: new WeakMap(),
+
+ /**
+ * Returns the player window if one exists and if it hasn't yet been closed.
+ *
+ * @param {PictureInPictureParent} pipActorRef
+ * Reference to the calling PictureInPictureParent actor
+ *
+ * @returns {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;
+ },
+
+ /**
+ * Get the PiP panel for a browser. Create the panel if needed.
+ * @param {Browser} browser The current browser
+ * @returns panel The panel element
+ */
+ getPanelForBrowser(browser) {
+ let panel = browser.ownerDocument.querySelector("#PictureInPicturePanel");
+
+ if (!panel) {
+ browser.ownerGlobal.ensureCustomElements("moz-toggle");
+ browser.ownerGlobal.ensureCustomElements("moz-support-link");
+ let template = browser.ownerDocument.querySelector(
+ "#PictureInPicturePanelTemplate"
+ );
+ let clone = template.content.cloneNode(true);
+ template.replaceWith(clone);
+
+ panel = this.getPanelForBrowser(browser);
+ }
+ return panel;
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "TabSwapPictureInPicture": {
+ this.onPipSwappedBrowsers(event);
+ break;
+ }
+ case "TabSelect": {
+ this.updatePlayingDurationHistograms();
+ break;
+ }
+ }
+ },
+
+ /**
+ * Increase the count of PiP windows for a given browser
+ * @param browser The browser to increase PiP count in browserWeakMap
+ */
+ addPiPBrowserToWeakMap(browser) {
+ let count = this.browserWeakMap.has(browser)
+ ? this.browserWeakMap.get(browser)
+ : 0;
+ this.browserWeakMap.set(browser, count + 1);
+
+ // If a browser is being added to the browserWeakMap, that means its
+ // probably a good time to make sure the playing duration histograms
+ // are up-to-date, as it means that we've either opened a new PiP
+ // player window, or moved the originating tab to another window.
+ this.updatePlayingDurationHistograms();
+ },
+
+ /**
+ * Increase the count of PiP windows for a given AppWindow.
+ *
+ * @param {Browser} browser
+ * The content browser that the originating video lives in and from which
+ * we'll read its parent window to increase PiP window count in originatingWinWeakMap.
+ */
+ addOriginatingWinToWeakMap(browser) {
+ let parentWin = browser.ownerGlobal;
+ let count = this.originatingWinWeakMap.get(parentWin);
+ if (!count || count == 0) {
+ this.setOriginatingWindowActive(parentWin.browsingContext, true);
+ this.originatingWinWeakMap.set(parentWin, 1);
+
+ let gBrowser = browser.getTabBrowser();
+ if (gBrowser) {
+ gBrowser.tabContainer.addEventListener("TabSelect", this);
+ }
+ } else {
+ this.originatingWinWeakMap.set(parentWin, count + 1);
+ }
+ },
+
+ /**
+ * Decrease the count of PiP windows for a given browser.
+ * If the count becomes 0, we will remove the browser from the WeakMap
+ * @param browser The browser to decrease PiP count in browserWeakMap
+ */
+ removePiPBrowserFromWeakMap(browser) {
+ let count = this.browserWeakMap.get(browser);
+ if (count <= 1) {
+ this.browserWeakMap.delete(browser);
+ let tabbrowser = browser.getTabBrowser();
+ if (tabbrowser && !tabbrowser.shouldActivateDocShell(browser)) {
+ browser.docShellIsActive = false;
+ }
+ } else {
+ this.browserWeakMap.set(browser, count - 1);
+ }
+ },
+
+ /**
+ * Decrease the count of PiP windows for a given AppWindow.
+ * If the count becomes 0, we will remove the AppWindow from the WeakMap.
+ *
+ * @param {Browser} browser
+ * The content browser that the originating video lives in and from which
+ * we'll read its parent window to decrease PiP window count in originatingWinWeakMap.
+ */
+ removeOriginatingWinFromWeakMap(browser) {
+ let parentWin = browser?.ownerGlobal;
+
+ if (!parentWin) {
+ return;
+ }
+
+ let count = this.originatingWinWeakMap.get(parentWin);
+ if (!count || count <= 1) {
+ this.originatingWinWeakMap.delete(parentWin, 0);
+ this.setOriginatingWindowActive(parentWin.browsingContext, false);
+
+ let gBrowser = browser.getTabBrowser();
+ if (gBrowser) {
+ gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ }
+ } else {
+ this.originatingWinWeakMap.set(parentWin, count - 1);
+ }
+ },
+
+ onPipSwappedBrowsers(event) {
+ let otherTab = event.detail;
+ if (otherTab) {
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (this.weakWinToBrowser.get(win) === event.target.linkedBrowser) {
+ this.weakWinToBrowser.set(win, otherTab.linkedBrowser);
+ this.removePiPBrowserFromWeakMap(event.target.linkedBrowser);
+ this.removeOriginatingWinFromWeakMap(event.target.linkedBrowser);
+ this.addPiPBrowserToWeakMap(otherTab.linkedBrowser);
+ this.addOriginatingWinToWeakMap(otherTab.linkedBrowser);
+ }
+ }
+ otherTab.addEventListener("TabSwapPictureInPicture", this);
+ }
+ },
+
+ updatePlayingDurationHistograms() {
+ // A tab switch occurred in a browser window with one more tabs that have
+ // PiP player windows associated with them.
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ let browser = this.weakWinToBrowser.get(win);
+ let gBrowser = browser.getTabBrowser();
+ if (gBrowser?.selectedBrowser == browser) {
+ // If there are any background stopwatches running for this window, finish
+ // them and switch to foreground.
+ if (TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, win)) {
+ TelemetryStopwatch.finish(BACKGROUND_DURATION_HISTOGRAM_ID, win);
+ }
+ if (
+ !TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, win)
+ ) {
+ TelemetryStopwatch.start(FOREGROUND_DURATION_HISTOGRAM_ID, win, {
+ inSeconds: true,
+ });
+ }
+ } else {
+ // If there are any foreground stopwatches running for this window, finish
+ // them and switch to background.
+ if (TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, win)) {
+ TelemetryStopwatch.finish(FOREGROUND_DURATION_HISTOGRAM_ID, win);
+ }
+
+ if (
+ !TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, win)
+ ) {
+ TelemetryStopwatch.start(BACKGROUND_DURATION_HISTOGRAM_ID, win, {
+ inSeconds: true,
+ });
+ }
+ }
+ }
+ },
+
+ /**
+ * Called when the browser UI handles the View:PictureInPicture command via
+ * the keyboard.
+ *
+ * @param {Event} event
+ */
+ onCommand(event) {
+ if (!lazy.PIP_ENABLED) {
+ return;
+ }
+
+ let win = event.target.ownerGlobal;
+ let bc = Services.focus.focusedContentBrowsingContext;
+ if (bc.top == win.gBrowser.selectedBrowser.browsingContext) {
+ let actor = bc.currentWindowGlobal.getActor("PictureInPictureLauncher");
+ actor.sendAsyncMessage("PictureInPicture:KeyToggle");
+ }
+ },
+
+ async focusTabAndClosePip(window, pipActor) {
+ let browser = this.weakWinToBrowser.get(window);
+ if (!browser) {
+ return;
+ }
+
+ let gBrowser = browser.getTabBrowser();
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // focus the tab's window
+ tab.ownerGlobal.focus();
+
+ gBrowser.selectedTab = tab;
+ await this.closeSinglePipWindow({ reason: "unpip", actorRef: pipActor });
+ },
+
+ /**
+ * Update the respect PiPDisabled pref value when the toggle is clicked.
+ * @param {Event} event The event from toggling the respect
+ * PiPDisabled in the PiP panel
+ */
+ toggleRespectDisablePip(event) {
+ let toggle = event.target;
+ let respectPipDisabled = !toggle.pressed;
+
+ Services.prefs.setBoolPref(
+ "media.videocontrols.picture-in-picture.respect-disablePictureInPicture",
+ respectPipDisabled
+ );
+
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "disrespect_disable",
+ "urlBar"
+ );
+ },
+
+ /**
+ * Updates the PiP count and PiPDisabled count of eligible PiP videos for a
+ * respective WindowGlobal.
+ * @param {BrowsingContext} browsingContext The BrowsingContext with eligible videos
+ * @param {Object} object
+ * pipCount: The number of eligible videos for the respective WindowGlobal
+ * pipDisabledCount: The number of disablePiP videos for the respective WindowGlobal
+ */
+ updateEligiblePipVideoCount(browsingContext, object) {
+ let windowGlobal = browsingContext.currentWindowGlobal;
+
+ if (windowGlobal) {
+ this.weakGlobalToEligiblePipCount.set(windowGlobal, object);
+ }
+ },
+
+ /**
+ * A generator function that yeilds a WindowGlobal, it's respective PiP
+ * count, and if any of the videos have PiPDisabled set.
+ * @param {Browser} browser The selected browser
+ */
+ *windowGlobalPipCountGenerator(browser) {
+ let contextsToVisit = [browser.browsingContext];
+ while (contextsToVisit.length) {
+ let currentBC = contextsToVisit.pop();
+ let windowGlobal = currentBC.currentWindowGlobal;
+
+ if (!windowGlobal) {
+ continue;
+ }
+
+ let { pipCount, pipDisabledCount } =
+ this.weakGlobalToEligiblePipCount.get(windowGlobal) || {
+ pipCount: 0,
+ pipDisabledCount: 0,
+ };
+
+ contextsToVisit.push(...currentBC.children);
+
+ yield { windowGlobal, pipCount, pipDisabledCount };
+ }
+ },
+
+ /**
+ * Gets the total eligible video count and total PiPDisabled count for a
+ * given browser.
+ * @param {Browser} browser The selected browser
+ * @returns Total count of eligible PiP videos for the selected broser
+ */
+ getEligiblePipVideoCount(browser) {
+ let totalPipCount = 0;
+ let totalPipDisabled = 0;
+
+ for (let {
+ pipCount,
+ pipDisabledCount,
+ } of this.windowGlobalPipCountGenerator(browser)) {
+ totalPipCount += pipCount;
+ totalPipDisabled += pipDisabledCount;
+ }
+
+ return { totalPipCount, totalPipDisabled };
+ },
+
+ /**
+ * This function updates the hover text on the urlbar PiP button when we enter or exit PiP
+ * @param {Document} document The window document
+ * @param {Element} pipToggle The urlbar PiP button
+ * @param {String} dataL10nId The data l10n id of the string we want to show
+ */
+ updateUrlbarHoverText(document, pipToggle, dataL10nId) {
+ let shortcut = document.getElementById("key_togglePictureInPicture");
+
+ document.l10n.setAttributes(pipToggle, dataL10nId, {
+ shortcut: ShortcutUtils.prettifyShortcut(shortcut),
+ });
+ },
+
+ /**
+ * Toggles the visibility of the PiP urlbar button. If the total video count
+ * is 1, then we will show the button. If any eligible video has PiPDisabled,
+ * then the button will show. Otherwise the button is hidden.
+ * @param {Browser} browser The selected browser
+ */
+ updateUrlbarToggle(browser) {
+ if (!lazy.PIP_ENABLED || !lazy.PIP_URLBAR_BUTTON) {
+ return;
+ }
+
+ let win = browser.ownerGlobal;
+ if (win.closed || win.gBrowser?.selectedBrowser !== browser) {
+ return;
+ }
+
+ let { totalPipCount, totalPipDisabled } =
+ this.getEligiblePipVideoCount(browser);
+
+ let pipToggle = win.document.getElementById("picture-in-picture-button");
+ if (
+ totalPipCount === 1 ||
+ (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED)
+ ) {
+ pipToggle.hidden = false;
+ lazy.PageActions.sendPlacedInUrlbarTrigger(pipToggle);
+ } else {
+ pipToggle.hidden = true;
+ }
+
+ let browserHasPip = !!this.browserWeakMap.get(browser);
+ if (browserHasPip) {
+ this.setUrlbarPipIconActive(browser.ownerGlobal);
+ } else {
+ this.setUrlbarPipIconInactive(browser.ownerGlobal);
+ }
+ },
+
+ /**
+ * Open the PiP panel if any video has PiPDisabled, otherwise finds the
+ * correct WindowGlobal to open the eligible PiP video.
+ * @param {Event} event Event from clicking the PiP urlbar button
+ */
+ toggleUrlbar(event) {
+ if (event.button !== 0) {
+ return;
+ }
+
+ let win = event.target.ownerGlobal;
+ let browser = win.gBrowser.selectedBrowser;
+
+ let pipPanel = this.getPanelForBrowser(browser);
+
+ for (let {
+ windowGlobal,
+ pipCount,
+ pipDisabledCount,
+ } of this.windowGlobalPipCountGenerator(browser)) {
+ if (
+ (pipDisabledCount > 0 && lazy.RESPECT_PIP_DISABLED) ||
+ (pipPanel && pipPanel.state !== "closed")
+ ) {
+ this.togglePipPanel(browser);
+ return;
+ } else if (pipCount === 1) {
+ let eventExtraKeys = {};
+ if (
+ !Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF) &&
+ lazy.ASRouter.initialized
+ ) {
+ let { messages, messageImpressions } = lazy.ASRouter.state;
+ let pipCallouts = messages.filter(
+ message =>
+ message.template === "feature_callout" &&
+ message.content.screens.some(screen =>
+ screen.anchors.some(anchor =>
+ anchor.selector.includes("picture-in-picture-button")
+ )
+ )
+ );
+ if (pipCallouts.length) {
+ // Has one of the callouts been seen in the last 48 hours?
+ let now = Date.now();
+ let callout = pipCallouts.some(message =>
+ messageImpressions[message.id]?.some(
+ impression => now - impression < 48 * 60 * 60 * 1000
+ )
+ );
+ if (callout) {
+ eventExtraKeys.callout = "true";
+ }
+ }
+ }
+ let actor = windowGlobal.getActor("PictureInPictureToggle");
+ actor.sendAsyncMessage("PictureInPicture:UrlbarToggle", eventExtraKeys);
+ return;
+ }
+ }
+ },
+
+ /**
+ * Set the toggle for PiPDisabled when the panel is shown.
+ * If the pref is set from about:config, we need to update
+ * the toggle switch in the panel to match the pref.
+ * @param {Event} event The panel shown event
+ */
+ onPipPanelShown(event) {
+ let toggle = event.target.querySelector("#respect-pipDisabled-switch");
+ toggle.pressed = !lazy.RESPECT_PIP_DISABLED;
+ },
+
+ /**
+ * Update the visibility of the urlbar PiP button when the panel is hidden.
+ * The button will show when there is more than 1 video and at least 1 video
+ * has PiPDisabled. If we no longer want to respect PiPDisabled then we
+ * need to check if the urlbar button should still be visible.
+ * @param {Event} event The panel hidden event
+ */
+ onPipPanelHidden(event) {
+ this.updateUrlbarToggle(event.view.gBrowser.selectedBrowser);
+ },
+
+ /**
+ * Create the PiP panel if needed and toggle the display of the panel
+ * @param {Browser} browser The current browser
+ */
+ togglePipPanel(browser) {
+ let pipPanel = this.getPanelForBrowser(browser);
+
+ if (pipPanel.state === "closed") {
+ let anchor = browser.ownerDocument.querySelector(
+ "#picture-in-picture-button"
+ );
+
+ pipPanel.openPopup(anchor, "bottomright topright");
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "opened_method",
+ "urlBar",
+ null,
+ { disableDialog: "true" }
+ );
+ } else {
+ pipPanel.hidePopup();
+ }
+ },
+
+ /**
+ * Sets the PiP urlbar to an active state. This changes the icon in the
+ * urlbar button to the unpip icon.
+ * @param {Window} win The current Window
+ */
+ setUrlbarPipIconActive(win) {
+ let pipToggle = win.document.getElementById("picture-in-picture-button");
+ pipToggle.toggleAttribute("pipactive", true);
+
+ this.updateUrlbarHoverText(
+ win.document,
+ pipToggle,
+ "picture-in-picture-urlbar-button-close"
+ );
+ },
+
+ /**
+ * Sets the PiP urlbar to an inactive state. This changes the icon in the
+ * urlbar button to the open pip icon.
+ * @param {Window} win The current window
+ */
+ setUrlbarPipIconInactive(win) {
+ if (!win) {
+ return;
+ }
+ let pipToggle = win.document.getElementById("picture-in-picture-button");
+ pipToggle.toggleAttribute("pipactive", false);
+
+ this.updateUrlbarHoverText(
+ win.document,
+ pipToggle,
+ "picture-in-picture-urlbar-button-open"
+ );
+ },
+
+ /**
+ * 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.getTabBrowser();
+ let tab = gBrowser?.getTabForBrowser(browser);
+ if (tab) {
+ tab.removeAttribute("pictureinpicture");
+ }
+ },
+
+ /**
+ * Closes and waits for passed PiP player window to finish closing.
+ *
+ * @param {Window} pipWin
+ * 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;
+ }
+ this.removePiPBrowserFromWeakMap(this.weakWinToBrowser.get(win));
+
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "closed_method",
+ reason,
+ null
+ );
+ await this.closePipWindow(win);
+ },
+
+ /**
+ * A request has come up from content to open a Picture in Picture
+ * window.
+ *
+ * @param {WindowGlobalParent} wgps
+ * The WindowGlobalParent that is requesting the Picture in Picture
+ * window.
+ *
+ * @param {object} videoData
+ * 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) {
+ 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);
+
+ this.setUrlbarPipIconActive(parentWin);
+
+ tab.addEventListener("TabSwapPictureInPicture", this);
+
+ let pipId = gNextWindowID.toString();
+ win.setupPlayer(pipId, wgp, videoData.videoRef);
+ gNextWindowID++;
+
+ this.weakWinToBrowser.set(win, browser);
+ this.addPiPBrowserToWeakMap(browser);
+ this.addOriginatingWinToWeakMap(browser);
+
+ win.setScrubberPosition(videoData.scrubberPosition);
+ win.setTimestamp(videoData.timestamp);
+ win.setVolume(videoData.volume);
+
+ Services.prefs.setBoolPref(TOGGLE_HAS_USED_PREF, true);
+
+ let args = {
+ width: win.innerWidth.toString(),
+ height: win.innerHeight.toString(),
+ screenX: win.screenX.toString(),
+ screenY: win.screenY.toString(),
+ ccEnabled: videoData.ccEnabled.toString(),
+ webVTTSubtitles: videoData.webVTTSubtitles.toString(),
+ };
+
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "create",
+ "player",
+ pipId,
+ args
+ );
+ },
+
+ /**
+ * Calls the browsingContext's `forceAppWindowActive` flag to determine if the
+ * the top level chrome browsingContext should be forcefully set as active or not.
+ * When the originating window's browsing context is set to active, captions on the
+ * PiP window are properly updated. Forcing active while a PiP window is open ensures
+ * that captions are still updated when the originating window is occluded.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context of the originating window
+ * @param {boolean} isActive
+ * True to force originating window as active, or false to not enforce it
+ * @see CanonicalBrowsingContext
+ */
+ setOriginatingWindowActive(browsingContext, isActive) {
+ browsingContext.forceAppWindowActive = isActive;
+ },
+
+ /**
+ * unload event has been called in player.js, cleanup our preserved
+ * browser object.
+ *
+ * @param {Window} window
+ */
+ unload(window) {
+ TelemetryStopwatch.finish(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
+ window
+ );
+
+ if (TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, window)) {
+ TelemetryStopwatch.finish(BACKGROUND_DURATION_HISTOGRAM_ID, window);
+ } else if (
+ TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, window)
+ ) {
+ TelemetryStopwatch.finish(FOREGROUND_DURATION_HISTOGRAM_ID, window);
+ }
+
+ let browser = this.weakWinToBrowser.get(window);
+ this.removeOriginatingWinFromWeakMap(browser);
+
+ gCurrentPlayerCount -= 1;
+ // Saves the location of the Picture in Picture window
+ this.savePosition(window);
+ this.clearPipTabIcon(window);
+ this.setUrlbarPipIconInactive(browser?.ownerGlobal);
+ },
+
+ /**
+ * Open a Picture in Picture window on the same screen as parentWin,
+ * sized based on the information in videoData.
+ *
+ * @param {ChromeWindow} parentWin
+ * The window hosting the browser that requested the Picture in
+ * Picture window.
+ *
+ * @param {object} videoData
+ * An object containing the following properties:
+ *
+ * videoHeight (int):
+ * The preferred height of the video.
+ *
+ * videoWidth (int):
+ * The preferred width of the video.
+ *
+ * @param {PictureInPictureParent} actorReference
+ * 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 { left: resolvedLeft, top: resolvedTop } = this.resolveOverlapConflicts(
+ left,
+ top,
+ width,
+ height
+ );
+
+ top = Math.round(resolvedTop);
+ left = Math.round(resolvedLeft);
+ width = Math.round(width);
+ height = Math.round(height);
+
+ let features =
+ `${PLAYER_FEATURES},top=${top},left=${left},outerWidth=${width},` +
+ `outerHeight=${height}`;
+ let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(parentWin);
+
+ if (isPrivate) {
+ features += ",private";
+ }
+
+ let pipWindow = Services.ww.openWindow(
+ parentWin,
+ PLAYER_URI,
+ null,
+ features,
+ null
+ );
+
+ TelemetryStopwatch.start(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
+ pipWindow,
+ {
+ inSeconds: true,
+ }
+ );
+
+ pipWindow.windowUtils.setResizeMargin(RESIZE_MARGIN_PX);
+
+ // If the window is Private the icon will have already been set when
+ // it was opened.
+ if (Services.appinfo.OS == "WINNT" && !isPrivate) {
+ lazy.WindowsUIUtils.setWindowIconNoData(pipWindow);
+ }
+
+ 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 {ChromeWindow|PlayerWindow} requestingWin
+ * 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 {object} videoData
+ * 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, in CSS pixels relative to
+ * requestingWin.
+ *
+ * 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 requestingCssToDesktopScale =
+ requestingWin.devicePixelRatio / requestingWin.desktopToDeviceScale;
+
+ let top, left, width, height;
+ if (!isPlayer) {
+ // requestingWin is a content window, load last PiP's dimensions
+ ({ top, left, width, height } = this.loadPosition());
+ } else if (requestingWin.windowState === requestingWin.STATE_FULLSCREEN) {
+ // `requestingWin` is a PiP window and in fullscreen. We stored the size
+ // and position before entering fullscreen and we will use that to
+ // calculate the new position
+ ({ top, left, width, height } = requestingWin.getDeferredResize());
+ left *= requestingCssToDesktopScale;
+ top *= requestingCssToDesktopScale;
+ } else {
+ // requestingWin is a PiP player, conserve its dimensions in this case
+ left = requestingWin.screenX * requestingCssToDesktopScale;
+ top = requestingWin.screenY * requestingCssToDesktopScale;
+ width = requestingWin.outerWidth;
+ height = requestingWin.outerHeight;
+ }
+
+ // Check that previous location and size were loaded.
+ // Note that at this point left and top are in desktop pixels, while width
+ // and height are in CSS pixels.
+ if (!isNaN(top) && !isNaN(left) && !isNaN(width) && !isNaN(height)) {
+ // Get the screen of the last PiP window. PiP screen will be the default
+ // screen if the point was not on a screen.
+ let PiPScreen = this.getWorkingScreen(left, top);
+
+ // Center position of PiP window.
+ let PipScreenCssToDesktopScale =
+ PiPScreen.defaultCSSScaleFactor / PiPScreen.contentsScaleFactor;
+ let centerX = left + (width * PipScreenCssToDesktopScale) / 2;
+ let centerY = top + (height * PipScreenCssToDesktopScale) / 2;
+
+ // 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 oldWidthDesktopPix = width * PipScreenCssToDesktopScale;
+
+ // 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;
+ }
+
+ let widthDesktopPix = width * PipScreenCssToDesktopScale;
+ let heightDesktopPix = height * PipScreenCssToDesktopScale;
+
+ // 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 + widthDesktopPix);
+ if (
+ 0 < distFromRight &&
+ distFromRight <= WIGGLE_ROOM + (oldWidthDesktopPix - widthDesktopPix)
+ ) {
+ 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;
+ }
+ if (top < PiPScreenTop) {
+ // off the top of the screen
+ // slide down
+ top = PiPScreenTop;
+ }
+ if (left + widthDesktopPix > PiPScreenLeft + PiPScreenWidth) {
+ // off the right of the screen
+ // slide left
+ left = PiPScreenLeft + PiPScreenWidth - widthDesktopPix;
+ }
+ if (top + heightDesktopPix > PiPScreenTop + PiPScreenHeight) {
+ // off the bottom of the screen
+ // slide up
+ top = PiPScreenTop + PiPScreenHeight - heightDesktopPix;
+ }
+ // Convert top / left from desktop to requestingWin-relative CSS pixels.
+ top /= requestingCssToDesktopScale;
+ left /= requestingCssToDesktopScale;
+ 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 * requestingCssToDesktopScale,
+ requestingWin.screenY * requestingCssToDesktopScale,
+ requestingWin.outerWidth * requestingCssToDesktopScale,
+ requestingWin.outerHeight * requestingCssToDesktopScale
+ );
+ let [screenLeft, screenTop, screenWidth, screenHeight] =
+ this.getAvailScreenSize(screen);
+
+ let screenCssToDesktopScale =
+ screen.defaultCSSScaleFactor / screen.contentsScaleFactor;
+
+ // 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 * screenCssToDesktopScale;
+ height = videoHeight * screenCssToDesktopScale;
+ 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;
+
+ // Convert top/left from desktop pixels to requestingWin-relative CSS
+ // pixels, and width / height to the target screen's CSS pixels, which is
+ // what we've made the size calculation against.
+ top /= requestingCssToDesktopScale;
+ left /= requestingCssToDesktopScale;
+ width /= screenCssToDesktopScale;
+ height /= screenCssToDesktopScale;
+
+ return { top, left, width, height };
+ },
+
+ /**
+ * This function will take the size and potential location of a new
+ * Picture-in-Picture player window, and try to return the location
+ * coordinates that will best ensure that the player window will not overlap
+ * with other pre-existing player windows.
+ *
+ * @param {int} left
+ * x position of left edge for Picture-in-Picture window that is being
+ * opened
+ * @param {int} top
+ * y position of top edge for Picture-in-Picture window that is being
+ * opened
+ * @param {int} width
+ * Width of Picture-in-Picture window that is being opened
+ * @param {int} height
+ * Height of Picture-in-Picture window that is being opened
+ *
+ * @returns {object}
+ * An object with the following properties:
+ *
+ * top (int):
+ * The recommended top position for the player window.
+ *
+ * left (int):
+ * The recommended left position for the player window.
+ */
+ resolveOverlapConflicts(left, top, width, height) {
+ // This algorithm works by first identifying the possible candidate
+ // locations that the new player window could be placed without overlapping
+ // other player windows (assuming that a confict is discovered at all of
+ // course). The optimal candidate is then selected by its distance to the
+ // original conflict, shorter distances are better.
+ //
+ // Candidates are discovered by iterating over each of the sides of every
+ // pre-existing player window. One candidate is collected for each side.
+ // This is done to ensure that the new player window will be opened to
+ // tightly fit along the edge of another player window.
+ //
+ // These candidates are then pruned for candidates that will introduce
+ // further conflicts. Finally the ideal candidate is selected from this
+ // pool of remaining candidates, optimized for minimizing distance to
+ // the original conflict.
+ let playerRects = [];
+
+ for (let playerWin of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ playerRects.push(
+ new Rect(
+ playerWin.screenX,
+ playerWin.screenY,
+ playerWin.outerWidth,
+ playerWin.outerHeight
+ )
+ );
+ }
+
+ const newPlayerRect = new Rect(left, top, width, height);
+ let conflictingPipRect = playerRects.find(rect =>
+ rect.intersects(newPlayerRect)
+ );
+
+ if (!conflictingPipRect) {
+ // no conflicts found
+ return { left, top };
+ }
+
+ const conflictLoc = conflictingPipRect.center();
+
+ // Will try to resolve a better placement only on the screen where
+ // the conflict occurred
+ const conflictScreen = this.getWorkingScreen(conflictLoc.x, conflictLoc.y);
+
+ const [screenTop, screenLeft, screenWidth, screenHeight] =
+ this.getAvailScreenSize(conflictScreen);
+
+ const screenRect = new Rect(
+ screenTop,
+ screenLeft,
+ screenWidth,
+ screenHeight
+ );
+
+ const getEdgeCandidates = rect => {
+ return [
+ // left edge's candidate
+ new Point(rect.left - newPlayerRect.width, rect.top),
+ // top edge's candidate
+ new Point(rect.left, rect.top - newPlayerRect.height),
+ // right edge's candidate
+ new Point(rect.right + newPlayerRect.width, rect.top),
+ // bottom edge's candidate
+ new Point(rect.left, rect.bottom),
+ ];
+ };
+
+ let candidateLocations = [];
+ for (const playerRect of playerRects) {
+ for (let candidateLoc of getEdgeCandidates(playerRect)) {
+ const candidateRect = new Rect(
+ candidateLoc.x,
+ candidateLoc.y,
+ width,
+ height
+ );
+
+ if (!screenRect.contains(candidateRect)) {
+ continue;
+ }
+
+ // test that no PiPs conflict with this candidate box
+ if (playerRects.some(rect => rect.intersects(candidateRect))) {
+ continue;
+ }
+
+ const candidateCenter = candidateRect.center();
+ const candidateDistanceToConflict =
+ Math.abs(conflictLoc.x - candidateCenter.x) +
+ Math.abs(conflictLoc.y - candidateCenter.y);
+
+ candidateLocations.push({
+ distanceToConflict: candidateDistanceToConflict,
+ location: candidateLoc,
+ });
+ }
+ }
+
+ if (!candidateLocations.length) {
+ // if no suitable candidates can be found, return the original location
+ return { left, top };
+ }
+
+ // sort candidates by distance to the conflict, select the closest
+ const closestCandidate = candidateLocations.sort(
+ (firstCand, secondCand) =>
+ firstCand.distanceToConflict - secondCand.distanceToConflict
+ )[0];
+
+ if (!closestCandidate) {
+ // can occur if there were no valid candidates, return original location
+ return { left, top };
+ }
+
+ const resolvedX = closestCandidate.location.x;
+ const resolvedY = closestCandidate.location.y;
+
+ return { left: resolvedX, top: resolvedY };
+ },
+
+ /**
+ * Resizes the the PictureInPicture player window.
+ *
+ * @param {object} videoData
+ * The source video's data.
+ * @param {PictureInPictureParent} actorRef
+ * Reference to the PictureInPicture parent actor.
+ */
+ resizePictureInPictureWindow(videoData, actorRef) {
+ let win = this.getWeakPipPlayer(actorRef);
+
+ if (!win) {
+ return;
+ }
+
+ win.resizeToVideo(this.fitToScreen(win, videoData));
+ },
+
+ /**
+ * Opens the context menu for toggling PictureInPicture.
+ *
+ * @param {Window} window
+ * @param {object} data
+ * Message data from the PictureInPictureToggleParent
+ */
+ openToggleContextMenu(window, data) {
+ let document = window.document;
+ let popup = document.getElementById("pictureInPictureToggleContextMenu");
+ let contextMoveToggle = document.getElementById(
+ "context_MovePictureInPictureToggle"
+ );
+
+ // Set directional string for toggle position
+ let position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+ switch (position) {
+ case TOGGLE_POSITION_RIGHT:
+ document.l10n.setAttributes(
+ contextMoveToggle,
+ "picture-in-picture-move-toggle-left"
+ );
+ break;
+ case TOGGLE_POSITION_LEFT:
+ document.l10n.setAttributes(
+ contextMoveToggle,
+ "picture-in-picture-move-toggle-right"
+ );
+ break;
+ }
+
+ // We synthesize a new MouseEvent to propagate the inputSource to the
+ // subsequently triggered popupshowing event.
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = data.screenXDevPx / window.devicePixelRatio;
+ let screenY = data.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null,
+ 0,
+ data.inputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+ },
+
+ hideToggle() {
+ Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false);
+ Services.telemetry.recordEvent(
+ "pictureinpicture.settings",
+ "disable",
+ "player"
+ );
+ },
+
+ /**
+ * This is used in AsyncTabSwitcher.sys.mjs and tabbrowser.js to check if the browser
+ * currently has a PiP window.
+ * If the browser has a PiP window we want to keep the browser in an active state because
+ * the browser is still partially visible.
+ * @param browser The browser to check if it has a PiP window
+ * @returns true if browser has PiP window else false
+ */
+ isOriginatingBrowser(browser) {
+ return this.browserWeakMap.has(browser);
+ },
+
+ moveToggle() {
+ // Get the current position
+ let position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+ let newPosition = "";
+ // Determine what the opposite position would be for that preference
+ switch (position) {
+ case TOGGLE_POSITION_RIGHT:
+ newPosition = TOGGLE_POSITION_LEFT;
+ break;
+ case TOGGLE_POSITION_LEFT:
+ newPosition = TOGGLE_POSITION_RIGHT;
+ break;
+ }
+ if (newPosition) {
+ Services.prefs.setStringPref(TOGGLE_POSITION_PREF, newPosition);
+ }
+ },
+
+ /**
+ * This function takes a screen and will return the left, top, width and
+ * height of the screen
+ * @param {Screen} screen
+ * The screen we need to get the size and coordinates of
+ *
+ * @returns {array}
+ * Size and location of screen in desktop pixels.
+ *
+ * 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
+ );
+ return [
+ screenLeft.value,
+ screenTop.value,
+ screenWidth.value,
+ screenHeight.value,
+ ];
+ },
+
+ /**
+ * This function takes in a rect in desktop pixels, and returns the screen it
+ * is located on.
+ *
+ * If the left and top are not on any screen, it will return the default
+ * screen.
+ *
+ * @param {int} left
+ * left or x coordinate
+ *
+ * @param {int} top
+ * top or y coordinate
+ *
+ * @param {int} width
+ * top or y coordinate
+ *
+ * @param {int} height
+ * top or y coordinate
+ *
+ * @returns {Screen} 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
+ return screenManager.screenForRect(left, top, width, height);
+ },
+
+ /**
+ * Saves position and size of Picture-in-Picture window
+ * @param {Window} win The Picture-in-Picture window
+ */
+ savePosition(win) {
+ let xulStore = Services.xulStore;
+
+ // We store left / top position in desktop pixels, like SessionStore does,
+ // so that we can restore them properly (as CSS pixels need to be relative
+ // to a screen, and we won't have a target screen to restore).
+ let cssToDesktopScale = win.devicePixelRatio / win.desktopToDeviceScale;
+
+ let left = win.screenX * cssToDesktopScale;
+ let top = win.screenY * cssToDesktopScale;
+ let width = win.outerWidth;
+ let height = win.outerHeight;
+
+ 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 };
+ },
+
+ setFirstSeen(dateSeconds) {
+ if (!dateSeconds) {
+ return;
+ }
+
+ Services.prefs.setIntPref(TOGGLE_FIRST_SEEN_PREF, dateSeconds);
+ },
+
+ setHasUsed(hasUsed) {
+ Services.prefs.setBoolPref(TOGGLE_HAS_USED_PREF, !!hasUsed);
+ },
+};
diff --git a/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs b/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs
new file mode 100644
index 0000000000..1f898a9078
--- /dev/null
+++ b/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs
@@ -0,0 +1,40 @@
+/* -*- 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/. */
+
+// These denote which keyboard controls to disable for a qualified video element.
+export const KEYBOARD_CONTROLS = {
+ ALL: 0,
+ PLAY_PAUSE: 1 << 0,
+ MUTE_UNMUTE: 1 << 1,
+ VOLUME: 1 << 2,
+ SEEK: 1 << 3,
+ CLOSE: 1 << 4,
+ LIVE_SEEK: 1 << 5,
+};
+
+// These are the possible toggle positions along the right side of
+// a qualified video element.
+export const TOGGLE_POLICIES = {
+ DEFAULT: 1,
+ HIDDEN: 2,
+ TOP: 3,
+ ONE_QUARTER: 4,
+ MIDDLE: 5,
+ THREE_QUARTERS: 6,
+ BOTTOM: 7,
+};
+
+// These strings are used in the videocontrols.css stylesheet as
+// toggle policy attribute values for setting rules on the position
+// of the toggle.
+export const TOGGLE_POLICY_STRINGS = {
+ [TOGGLE_POLICIES.DEFAULT]: "default",
+ [TOGGLE_POLICIES.HIDDEN]: "hidden",
+ [TOGGLE_POLICIES.TOP]: "top",
+ [TOGGLE_POLICIES.ONE_QUARTER]: "one-quarter",
+ [TOGGLE_POLICIES.MIDDLE]: "middle",
+ [TOGGLE_POLICIES.THREE_QUARTERS]: "three-quarters",
+ [TOGGLE_POLICIES.BOTTOM]: "bottom",
+};
diff --git a/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml
new file mode 100644
index 0000000000..fb4b32a1d2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml
@@ -0,0 +1,46 @@
+<!-- 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:template id="PictureInPicturePanelTemplate">
+ <panel
+ id="PictureInPicturePanel"
+ class="panel-no-padding"
+ type="arrow"
+ orient="vertical"
+ level="parent"
+ tabspecific="true"
+ onpopupshown="PictureInPicture.onPipPanelShown(event);"
+ onpopuphidden="PictureInPicture.onPipPanelHidden(event)"
+ >
+ <box class="panel-header">
+ <html:h1>
+ <html:span data-l10n-id="picture-in-picture-panel-header" />
+ </html:h1>
+ </box>
+ <toolbarseparator />
+ <vbox id="PictureInPicturePanelBody">
+ <html:div data-l10n-id="picture-in-picture-panel-headline" />
+ <html:div>
+ <html:span
+ class="deemphasized"
+ data-l10n-id="picture-in-picture-panel-body"
+ />
+ <html:a
+ is="moz-support-link"
+ id="pip-learn-more-link"
+ support-page="about-picture-picture-firefox"
+ />
+ </html:div>
+ </vbox>
+ <toolbarseparator />
+ <vbox id="PictureInPicturePanelFooter">
+ <html:moz-toggle
+ id="respect-pipDisabled-switch"
+ data-l10n-id="picture-in-picture-enable-toggle"
+ data-l10n-attrs="label"
+ onclick="PictureInPicture.toggleRespectDisablePip(event);"
+ />
+ </vbox>
+ </panel>
+</html:template>
diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js
new file mode 100644
index 0000000000..c60c2ca44e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/content/player.js
@@ -0,0 +1,1407 @@
+/* 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.importESModule(
+ "resource://gre/modules/PictureInPicture.sys.mjs"
+);
+const { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+);
+const { DeferredTask } = ChromeUtils.importESModule(
+ "resource://gre/modules/DeferredTask.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+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";
+const CAPTIONS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled";
+const CAPTIONS_TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.display-text-tracks.toggle.enabled";
+const TEXT_TRACK_FONT_SIZE_PREF =
+ "media.videocontrols.picture-in-picture.display-text-tracks.size";
+const IMPROVED_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
+const SEETHROUGH_MODE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.seethrough-mode.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 {Number} id
+ * A unique numeric ID for the window, used for Telemetry Events.
+ * @param {WindowGlobalParent} wgp
+ * The WindowGlobalParent that is hosting the originating video.
+ * @param {ContentDOMReference} videoRef
+ * 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 {Boolean} isPlaying
+ * 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 {Boolean} isMuted
+ * True if the Picture-in-Picture video is muted.
+ */
+function setIsMutedState(isMuted) {
+ Player.isMuted = isMuted;
+}
+
+/**
+ * Function to resize and reposition the PiP window
+ * @param {Object} rect
+ * An object containing `left`, `top`, `width`, and `height` for the PiP
+ * window
+ */
+function resizeToVideo(rect) {
+ Player.resizeToVideo(rect);
+}
+
+/**
+ * Returns an object containing `left`, `top`, `width`, and `height` of the
+ * PiP window before entering fullscreen. Will be null if the PiP window is
+ * not in fullscreen.
+ */
+function getDeferredResize() {
+ return Player.deferredResize;
+}
+
+function enableSubtitlesButton() {
+ Player.enableSubtitlesButton();
+}
+
+function disableSubtitlesButton() {
+ Player.disableSubtitlesButton();
+}
+
+function setScrubberPosition(position) {
+ Player.setScrubberPosition(position);
+}
+
+function setTimestamp(timeString) {
+ Player.setTimestamp(timeString);
+}
+
+function setVolume(volume) {
+ Player.setVolume(volume);
+}
+
+/**
+ * The Player object handles initializing the player, holds state, and handles
+ * events for updating state.
+ */
+let Player = {
+ _isInitialized: false,
+ WINDOW_EVENTS: [
+ "click",
+ "contextmenu",
+ "dblclick",
+ "keydown",
+ "mouseup",
+ "mousemove",
+ "MozDOMFullscreen:Entered",
+ "MozDOMFullscreen:Exited",
+ "resize",
+ "unload",
+ "draggableregionleftmousedown",
+ ],
+ 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 Telemetry to identify the window.
+ */
+ 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,
+
+ /**
+ * Used to determine if hovering the mouse cursor over the pip window or not.
+ * Gets updated whenever a new hover state is detected.
+ */
+ isCurrentHover: false,
+
+ /**
+ * Store the size and position of the window before entering fullscreen and
+ * use this to correctly position the window when exiting fullscreen
+ */
+ deferredResize: null,
+
+ /**
+ * Initializes the player browser, and sets up the initial state.
+ *
+ * @param {Number} id
+ * A unique numeric ID for the window, used for Telemetry Events.
+ * @param {WindowGlobalParent} wgp
+ * The WindowGlobalParent that is hosting the originating video.
+ * @param {ContentDOMReference} videoRef
+ * A reference to the video element that a Picture-in-Picture window
+ * is being created for
+ */
+ init(id, wgp, videoRef) {
+ this.id = id;
+
+ // State for whether or not we are adjusting the time via the scrubber
+ this.scrubbing = false;
+
+ let holder = document.querySelector(".player-holder");
+ let browser = document.getElementById("browser");
+ browser.remove();
+
+ browser.setAttribute("nodefaultsrc", "true");
+
+ this.setupTooltip("close", "pictureinpicture-close-btn", "closeShortcut");
+ let strId = this.isFullscreen
+ ? `pictureinpicture-exit-fullscreen-btn2`
+ : `pictureinpicture-fullscreen-btn2`;
+ this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");
+
+ // 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);
+ }
+
+ this.controls.addEventListener("mouseleave", () => {
+ this.onMouseLeave();
+ });
+ this.controls.addEventListener("mouseenter", () => {
+ this.onMouseEnter();
+ });
+
+ this.scrubber.addEventListener("input", event => {
+ this.handleScrubbing(event);
+ });
+ this.scrubber.addEventListener("change", event => {
+ this.handleScrubbingDone(event);
+ });
+
+ this.audioScrubber.addEventListener("input", event => {
+ this.audioScrubbing = true;
+ this.handleAudioScrubbing(event.target.value);
+ });
+ this.audioScrubber.addEventListener("change", event => {
+ this.audioScrubbing = false;
+ });
+ this.audioScrubber.addEventListener("pointerdown", event => {
+ if (this.isMuted) {
+ this.audioScrubber.max = 1;
+ }
+ });
+
+ for (let radio of document.querySelectorAll(
+ 'input[type=radio][name="cc-size"]'
+ )) {
+ radio.addEventListener("change", event => {
+ this.onSubtitleChange(event.target.id);
+ });
+ }
+
+ document
+ .querySelector("#subtitles-toggle")
+ .addEventListener("change", () => {
+ this.onToggleChange();
+ });
+
+ // 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;
+
+ const audioScrubber = document.getElementById("audio-scrubber");
+ audioScrubber.hidden = false;
+ }
+
+ if (Services.prefs.getBoolPref(CAPTIONS_ENABLED_PREF, false)) {
+ this.closedCaptionButton.hidden = false;
+ }
+
+ if (Services.prefs.getBoolPref(IMPROVED_CONTROLS_ENABLED_PREF, false)) {
+ const fullscreenButton = document.getElementById("fullscreen");
+ fullscreenButton.hidden = false;
+
+ const seekBackwardButton = document.getElementById("seekBackward");
+ seekBackwardButton.hidden = false;
+
+ const seekForwardButton = document.getElementById("seekForward");
+ seekForwardButton.hidden = false;
+
+ this.scrubber.hidden = false;
+ this.timestamp.hidden = false;
+
+ const controlsBottomGradient = document.getElementById(
+ "controls-bottom-gradient"
+ );
+ controlsBottomGradient.hidden = false;
+ }
+
+ this.alignEndControlsButtonTooltips();
+
+ this.resizeDebouncer = new DeferredTask(() => {
+ this.alignEndControlsButtonTooltips();
+ this.recordEvent("resize", {
+ width: window.outerWidth.toString(),
+ height: window.outerHeight.toString(),
+ });
+ }, RESIZE_DEBOUNCE_RATE_MS);
+
+ 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();
+ });
+
+ let fontSize = Services.prefs.getCharPref(
+ TEXT_TRACK_FONT_SIZE_PREF,
+ "medium"
+ );
+
+ // fallback to medium if the pref value is not a valid option
+ if (fontSize === "small" || fontSize === "large") {
+ document.querySelector(`#${fontSize}`).checked = "true";
+ } else {
+ document.querySelector("#medium").checked = "true";
+ }
+
+ // In see-through mode the PiP window is made semi-transparent on hover.
+ if (Services.prefs.getBoolPref(SEETHROUGH_MODE_ENABLED_PREF, false)) {
+ document.documentElement.classList.add("seethrough-mode");
+ }
+
+ this._isInitialized = true;
+ },
+
+ uninit() {
+ this.resizeDebouncer.disarm();
+ PictureInPicture.unload(window, this.actor);
+ },
+
+ setupTooltip(elId, l10nId, shortcutId) {
+ const el = document.getElementById(elId);
+ const shortcut = document.getElementById(shortcutId);
+ let l10nObj = shortcut
+ ? { shortcut: ShortcutUtils.prettifyShortcut(shortcut) }
+ : {};
+ document.l10n.setAttributes(el, l10nId, l10nObj);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click": {
+ // Don't run onClick if middle or right click is pressed respectively
+ if (event.button !== 1 && event.button !== 2) {
+ 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);
+ this.showVideoControls();
+ } else if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ let isSettingsPanelInFocus = this.settingsPanel.contains(
+ document.activeElement
+ );
+
+ event.preventDefault();
+
+ if (!this.settingsPanel.classList.contains("hide")) {
+ // If the subtitles settings panel is open, let the ESC key close it
+ this.toggleSubtitlesSettingsPanel({ forceHide: true });
+ if (isSettingsPanelInFocus) {
+ document.getElementById("closed-caption").focus();
+ }
+ } else if (this.isFullscreen) {
+ // We handle the ESC key, in fullscreen modus as intent to leave only the fullscreen mode
+ document.exitFullscreen();
+ } else {
+ // We handle the ESC key, as an intent to leave the picture-in-picture modus
+ this.onClose();
+ }
+ } 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;
+ }
+
+ case "mousemove": {
+ this.onMouseMove();
+ 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");
+ }
+ });
+
+ // If we are exiting fullscreen we want to resize the window to the
+ // stored size and position
+ if (this.deferredResize && event.type === "MozDOMFullscreen:Exited") {
+ this.resizeToVideo(this.deferredResize);
+ this.deferredResize = null;
+ }
+
+ // Sets the title for fullscreen button when PIP is in Enter Fullscreen mode and Exit Fullscreen mode
+ let strId = this.isFullscreen
+ ? `pictureinpicture-exit-fullscreen-btn2`
+ : `pictureinpicture-fullscreen-btn2`;
+ this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");
+
+ window.focus();
+
+ if (this.isFullscreen) {
+ this.actor.sendAsyncMessage("PictureInPicture:EnterFullscreen", {
+ isFullscreen: true,
+ isVideoControlsShowing: null,
+ playerBottomControlsDOMRect: null,
+ });
+ } else {
+ this.actor.sendAsyncMessage("PictureInPicture:ExitFullscreen", {
+ isFullscreen: this.isFullscreen,
+ isVideoControlsShowing:
+ !!this.controls.getAttribute("showing") ||
+ !!this.controls.getAttribute("keying"),
+ playerBottomControlsDOMRect:
+ this.controlsBottom.getBoundingClientRect(),
+ });
+ }
+ // The subtitles settings panel gets selected when entering/exiting fullscreen even though
+ // user-select is set to none. I don't know why this happens or how to prevent so we just
+ // remove the selection when fullscreen is entered/exited.
+ let selection = window.getSelection();
+ selection.removeAllRanges();
+ break;
+ }
+
+ case "oop-browser-crashed": {
+ this.closePipWindow({ reason: "browser-crash" });
+ break;
+ }
+
+ case "resize": {
+ this.onResize(event);
+ break;
+ }
+
+ case "unload": {
+ this.uninit();
+ break;
+ }
+
+ case "draggableregionleftmousedown": {
+ this.toggleSubtitlesSettingsPanel({ forceHide: true });
+ break;
+ }
+ }
+ },
+
+ /**
+ * This function handles when the scrubber is being scrubbed by the mouse
+ * because if we get an input event from the keyboard, onKeyDown will set
+ * this.preventNextInputEvent to true.
+ * This function is called by input events on the scrubber
+ * @param {Event} event The input event
+ */
+ handleScrubbing(event) {
+ // When using the keyboard to scrub, we get both a keydown and an input
+ // event. The input event is fired after the keydown and we have already
+ // handled the keydown event in onKeyDown so we set preventNextInputEvent
+ // to true in onKeyDown as to not set the current time twice.
+ if (this.preventNextInputEvent) {
+ this.preventNextInputEvent = false;
+ return;
+ }
+ if (!this.scrubbing) {
+ this.wasPlaying = this.isPlaying;
+ if (this.isPlaying) {
+ this.actor.sendAsyncMessage("PictureInPicture:Pause");
+ }
+ this.scrubbing = true;
+ }
+ let scrubberPosition = this.getScrubberPositionFromEvent(event);
+ this.setVideoTime(scrubberPosition);
+ },
+
+ /**
+ * This function handles setting the scrubbing state to false and playing
+ * the video if we paused it before scrubbing.
+ * @param {Event} event The change event
+ */
+ handleScrubbingDone(event) {
+ if (!this.scrubbing) {
+ return;
+ }
+ let scrubberPosition = this.getScrubberPositionFromEvent(event);
+ this.setVideoTime(scrubberPosition);
+ if (this.wasPlaying) {
+ this.actor.sendAsyncMessage("PictureInPicture:Play");
+ }
+ this.scrubbing = false;
+ },
+
+ /**
+ * Set the volume on the video and unmute if the video was muted.
+ * If the volume is changed via the keyboard, onKeyDown will set
+ * this.preventNextInputEvent to true.
+ * @param {Number} volume A number between 0 and 1 that represents the volume
+ */
+ handleAudioScrubbing(volume) {
+ // When using the keyboard to adjust the volume, we get both a keydown and
+ // an input event. The input event is fired after the keydown event and we
+ // have already handled the keydown event in onKeyDown so we set
+ // preventNextInputEvent to true in onKeyDown as to not set the volume twice.
+ if (this.preventNextInputEvent) {
+ this.preventNextInputEvent = false;
+ return;
+ }
+
+ if (this.isMuted) {
+ this.isMuted = false;
+ this.actor.sendAsyncMessage("PictureInPicture:Unmute");
+ }
+
+ if (volume == 0) {
+ this.actor.sendAsyncMessage("PictureInPicture:Mute");
+ }
+
+ this.actor.sendAsyncMessage("PictureInPicture:SetVolume", {
+ volume,
+ });
+ },
+
+ getScrubberPositionFromEvent(event) {
+ return event.target.value;
+ },
+
+ setVideoTime(scrubberPosition) {
+ let wasPlaying = this.scrubbing ? this.wasPlaying : this.isPlaying;
+ this.setScrubberPosition(scrubberPosition);
+ this.actor.sendAsyncMessage("PictureInPicture:SetVideoTime", {
+ scrubberPosition,
+ wasPlaying,
+ });
+ },
+
+ setScrubberPosition(value) {
+ this.scrubber.value = value;
+ this.scrubber.hidden = value === undefined;
+
+ // Also hide the seek buttons when we hide the scrubber
+ this.seekBackward.hidden = value === undefined;
+ this.seekForward.hidden = value === undefined;
+ },
+
+ setTimestamp(timestamp) {
+ this.timestamp.textContent = timestamp;
+ this.timestamp.hidden = timestamp === undefined;
+ },
+
+ setVolume(volume) {
+ if (volume < Number.EPSILON) {
+ this.actor.sendAsyncMessage("PictureInPicture:Mute");
+ }
+
+ this.audioScrubber.value = volume;
+ },
+
+ closePipWindow(closeData) {
+ // Set the subtitles font size prefs
+ Services.prefs.setBoolPref(
+ CAPTIONS_TOGGLE_ENABLED_PREF,
+ document.querySelector("#subtitles-toggle").checked
+ );
+ for (let radio of document.querySelectorAll(
+ 'input[type=radio][name="cc-size"]'
+ )) {
+ if (radio.checked) {
+ Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, radio.id);
+ break;
+ }
+ }
+ const { reason } = closeData;
+ PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor });
+ },
+
+ onDblClick(event) {
+ if (event.target.id == "controls") {
+ this.fullscreenModeToggle();
+ event.preventDefault();
+ }
+ },
+
+ onClick(event) {
+ switch (event.target.id) {
+ case "audio": {
+ this.toggleMute();
+ break;
+ }
+
+ case "close": {
+ this.onClose();
+ 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 "seekBackward": {
+ this.actor.sendAsyncMessage("PictureInPicture:SeekBackward");
+ break;
+ }
+
+ case "seekForward": {
+ this.actor.sendAsyncMessage("PictureInPicture:SeekForward");
+ break;
+ }
+
+ case "unpip": {
+ PictureInPicture.focusTabAndClosePip(window, this.actor);
+ break;
+ }
+
+ case "closed-caption": {
+ let options = {};
+ if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ options.isKeyboard = true;
+ }
+ this.toggleSubtitlesSettingsPanel(options);
+ // Early return to prevent hiding the panel below
+ return;
+ }
+
+ case "fullscreen": {
+ this.fullscreenModeToggle();
+ this.recordEvent("fullscreen", {
+ enter: (!this.isFullscreen).toString(),
+ });
+ break;
+ }
+
+ case "font-size-selection-radio-small": {
+ document.getElementById("small").click();
+ break;
+ }
+
+ case "font-size-selection-radio-medium": {
+ document.getElementById("medium").click();
+ break;
+ }
+
+ case "font-size-selection-radio-large": {
+ document.getElementById("large").click();
+ break;
+ }
+ }
+ // If the click came from a element that is not inside the subtitles settings panel
+ // then we want to hide the panel
+ if (!this.settingsPanel.contains(event.target)) {
+ this.toggleSubtitlesSettingsPanel({ forceHide: true });
+ }
+ },
+
+ /**
+ * Function to toggle the visibility of the subtitles settings panel
+ * @param {Object} options [optional] Object containing options for the function
+ * - forceHide: true to force hide the subtitles settings panel
+ * - isKeyboard: true if the subtitles button was activated using the keyboard
+ * to show or hide the subtitles settings panel
+ */
+ toggleSubtitlesSettingsPanel(options) {
+ let settingsPanelVisible = !this.settingsPanel.classList.contains("hide");
+ if (options?.forceHide || settingsPanelVisible) {
+ this.settingsPanel.classList.add("hide");
+ this.closedCaptionButton.setAttribute("aria-expanded", false);
+ this.controls.removeAttribute("donthide");
+
+ if (
+ this.controls.getAttribute("keying") ||
+ this.isCurrentHover ||
+ this.controls.getAttribute("showing")
+ ) {
+ return;
+ }
+
+ this.hideVideoControls();
+ } else {
+ this.settingsPanel.classList.remove("hide");
+ this.closedCaptionButton.setAttribute("aria-expanded", true);
+ this.controls.setAttribute("donthide", true);
+ this.showVideoControls();
+
+ if (options?.isKeyboard) {
+ document.querySelector("#subtitles-toggle").focus();
+ }
+ }
+ },
+
+ onClose() {
+ this.actor.sendAsyncMessage("PictureInPicture:Pause", {
+ reason: "pip-closed",
+ });
+ this.closePipWindow({ reason: "closeButton" });
+ },
+
+ fullscreenModeToggle() {
+ if (this.isFullscreen) {
+ document.exitFullscreen();
+ } else {
+ this.deferredResize = {
+ left: window.screenX,
+ top: window.screenY,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+ document.body.requestFullscreen();
+ }
+ },
+
+ /**
+ * Toggle the mute state of the video
+ */
+ toggleMute() {
+ if (this.isMuted) {
+ // We unmute in handleAudioScrubbing so no need to also do it here
+ this.audioScrubber.max = 1;
+ this.handleAudioScrubbing(this.lastVolume ?? 1);
+ } else {
+ this.lastVolume = this.audioScrubber.value;
+ this.actor.sendAsyncMessage("PictureInPicture:Mute");
+ }
+ },
+
+ resizeToVideo(rect) {
+ if (this.isFullscreen) {
+ // We store the size and position because resizing the PiP window
+ // while fullscreened will cause issues
+ this.deferredResize = rect;
+ } else {
+ let { left, top, width, height } = rect;
+ window.resizeTo(width, height);
+ window.moveTo(left, top);
+ }
+ },
+
+ onKeyDown(event) {
+ // We don't want to send a keydown event if the event target was one of the
+ // font sizes in the settings panel
+ if (
+ event.target.parentElement?.parentElement?.classList?.contains(
+ "font-size-selection"
+ )
+ ) {
+ return;
+ }
+
+ let eventKeys = {
+ altKey: event.altKey,
+ shiftKey: event.shiftKey,
+ metaKey: event.metaKey,
+ ctrlKey: event.ctrlKey,
+ keyCode: event.keyCode,
+ };
+
+ // If the up or down arrow is pressed while the scrubber is focused then we
+ // want to hijack these keydown events to act as left or right arrows
+ // respectively to correctly seek the video.
+ if (
+ event.target.id === "scrubber" &&
+ event.keyCode === window.KeyEvent.DOM_VK_UP
+ ) {
+ eventKeys.keyCode = window.KeyEvent.DOM_VK_RIGHT;
+ } else if (
+ event.target.id === "scrubber" &&
+ event.keyCode === window.KeyEvent.DOM_VK_DOWN
+ ) {
+ eventKeys.keyCode = window.KeyEvent.DOM_VK_LEFT;
+ }
+
+ // If the left or right arrow is pressed while the audio scrubber is focused
+ // then we want to hijack these keydown events to act as up or down arrows
+ // respectively to correctly change the volume.
+ if (
+ event.target.id === "audio-scrubber" &&
+ event.keyCode === window.KeyEvent.DOM_VK_RIGHT
+ ) {
+ eventKeys.keyCode = window.KeyEvent.DOM_VK_UP;
+ } else if (
+ event.target.id === "audio-scrubber" &&
+ event.keyCode === window.KeyEvent.DOM_VK_LEFT
+ ) {
+ eventKeys.keyCode = window.KeyEvent.DOM_VK_DOWN;
+ }
+
+ // If the keydown event was one of the arrow keys and the scrubber or the
+ // audio scrubber was focused then we want to prevent the subsequent input
+ // event from overwriting the keydown event.
+ if (
+ event.target.id === "audio-scrubber" ||
+ (event.target.id === "scrubber" &&
+ [
+ window.KeyEvent.DOM_VK_LEFT,
+ window.KeyEvent.DOM_VK_RIGHT,
+ window.KeyEvent.DOM_VK_UP,
+ window.KeyEvent.DOM_VK_DOWN,
+ ].includes(event.keyCode))
+ ) {
+ this.preventNextInputEvent = true;
+ }
+
+ this.actor.sendAsyncMessage("PictureInPicture:KeyDown", eventKeys);
+ },
+
+ onSubtitleChange(size) {
+ Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, size);
+
+ this.actor.sendAsyncMessage("PictureInPicture:ChangeFontSizeTextTracks");
+ },
+
+ onToggleChange() {
+ // The subtitles toggle has been click in the settings panel so we toggle
+ // the overlay above the font sizes and send a message to toggle the
+ // visibility of the subtitles and set the toggle pref
+ document
+ .querySelector(".font-size-selection")
+ .classList.toggle("font-size-overlay");
+ this.actor.sendAsyncMessage("PictureInPicture:ToggleTextTracks");
+
+ this.captionsToggleEnabled = !this.captionsToggleEnabled;
+ Services.prefs.setBoolPref(
+ CAPTIONS_TOGGLE_ENABLED_PREF,
+ this.captionsToggleEnabled
+ );
+ },
+
+ /**
+ * 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
+ );
+ },
+
+ /**
+ * Uses the PiP window's change in position to determine which direction
+ * the window has been moved in.
+ */
+ 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;
+ },
+
+ /**
+ * Event handler for "mouseup" events on the PiP window.
+ *
+ * @param {Event} event
+ * Event context details
+ */
+ onMouseUp(event) {
+ // Corner snapping changes start here.
+ // Check if metakey pressed and macOS
+ let quadrant = this.determineCurrentQuadrant();
+ let dragAction = this.determineDirectionDragged();
+
+ if (
+ ((event.ctrlKey && AppConstants.platform !== "macosx") ||
+ (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;
+ },
+
+ /**
+ * Event handler for mousemove the PiP Window
+ */
+ onMouseMove() {
+ if (this.isFullscreen) {
+ this.revealControls(false);
+ }
+ },
+
+ onMouseEnter() {
+ if (!this.isFullscreen) {
+ this.isCurrentHover = true;
+ this.showVideoControls();
+ }
+ },
+
+ onMouseLeave() {
+ if (!this.isFullscreen) {
+ this.isCurrentHover = false;
+ if (
+ !this.controls.getAttribute("showing") &&
+ !this.controls.getAttribute("keying") &&
+ !this.controls.getAttribute("donthide")
+ ) {
+ this.hideVideoControls();
+ }
+ }
+ },
+
+ enableSubtitlesButton() {
+ this.closedCaptionButton.disabled = false;
+
+ this.alignEndControlsButtonTooltips();
+ this.captionsToggleEnabled = true;
+ // If the CAPTIONS_TOGGLE_ENABLED_PREF pref is false then we will click
+ // the UI toggle to change the toggle to unchecked. This will call
+ // onToggleChange where this.captionsToggleEnabled will be updated
+ if (!Services.prefs.getBoolPref(CAPTIONS_TOGGLE_ENABLED_PREF, true)) {
+ document.querySelector("#subtitles-toggle").click();
+ }
+ },
+
+ disableSubtitlesButton() {
+ this.closedCaptionButton.disabled = true;
+
+ this.alignEndControlsButtonTooltips();
+ },
+
+ /**
+ * Sets focus state inline end tooltip for rightmost playback controls
+ */
+ alignEndControlsButtonTooltips() {
+ let audioBtn = document.getElementById("audio");
+ let width = window.outerWidth;
+
+ if (300 < width && width <= 400) {
+ audioBtn.classList.replace("center-tooltip", "inline-end-tooltip");
+ } else {
+ audioBtn.classList.replace("inline-end-tooltip", "center-tooltip");
+ }
+ },
+
+ /**
+ * Event handler for resizing the PiP Window
+ *
+ * @param {Event} event
+ * Event context data object
+ */
+ onResize(event) {
+ this.toggleSubtitlesSettingsPanel({ forceHide: true });
+ this.resizeDebouncer.disarm();
+ this.resizeDebouncer.arm();
+ },
+
+ /**
+ * Event handler for user issued commands
+ *
+ * @param {Event} event
+ * Event context data object
+ */
+ onCommand(event) {
+ this.closePipWindow({ reason: "shortcut" });
+ },
+
+ get controls() {
+ delete this.controls;
+ return (this.controls = document.getElementById("controls"));
+ },
+
+ get scrubber() {
+ delete this.scrubber;
+ return (this.scrubber = document.getElementById("scrubber"));
+ },
+
+ get audioScrubber() {
+ delete this.audioScrubber;
+ return (this.audioScrubber = document.getElementById("audio-scrubber"));
+ },
+
+ get timestamp() {
+ delete this.timestamp;
+ return (this.timestamp = document.getElementById("timestamp"));
+ },
+
+ get controlsBottom() {
+ delete this.controlsBottom;
+ return (this.controlsBottom = document.getElementById("controls-bottom"));
+ },
+
+ get seekBackward() {
+ delete this.seekBackward;
+ return (this.seekBackward = document.getElementById("seekBackward"));
+ },
+
+ get seekForward() {
+ delete this.seekForward;
+ return (this.seekForward = document.getElementById("seekForward"));
+ },
+
+ get closedCaptionButton() {
+ delete this.closedCaptionButton;
+ return (this.closedCaptionButton =
+ document.getElementById("closed-caption"));
+ },
+
+ get settingsPanel() {
+ delete this.settingsPanel;
+ return (this.settingsPanel = document.getElementById("settings"));
+ },
+
+ _isPlaying: false,
+ /**
+ * GET isPlaying returns true if the video is currently playing.
+ *
+ * SET isPlaying to true if the video is playing, false otherwise. This will
+ * update the internal state and displayed controls.
+ *
+ * @type {Boolean}
+ */
+ get isPlaying() {
+ return this._isPlaying;
+ },
+
+ set isPlaying(isPlaying) {
+ this._isPlaying = isPlaying;
+ this.controls.classList.toggle("playing", isPlaying);
+ let strId = isPlaying
+ ? `pictureinpicture-pause-btn`
+ : `pictureinpicture-play-btn`;
+ this.setupTooltip("playpause", strId);
+
+ if (
+ !this._isInitialized ||
+ this.isCurrentHover ||
+ this.controls.getAttribute("keying")
+ ) {
+ return;
+ }
+
+ if (!isPlaying) {
+ this.revealControls(true);
+ } else {
+ this.revealControls(false);
+ }
+ },
+
+ _isMuted: false,
+ /**
+ * GET isMuted returns true if the video is currently muted.
+ *
+ * SET isMuted to true if the video is muted, false otherwise. This will
+ * update the internal state and displayed controls.
+ *
+ * @type {Boolean}
+ */
+ get isMuted() {
+ return this._isMuted;
+ },
+
+ set isMuted(isMuted) {
+ this._isMuted = isMuted;
+ if (!isMuted) {
+ this.audioScrubber.max = 1;
+ } else if (!this.audioScrubbing) {
+ this.audioScrubber.max = 0;
+ }
+ this.controls.classList.toggle("muted", isMuted);
+ let strId = isMuted
+ ? `pictureinpicture-unmute-btn`
+ : `pictureinpicture-mute-btn`;
+ let shortcutId = isMuted ? "unMuteShortcut" : "muteShortcut";
+ this.setupTooltip("audio", strId, shortcutId);
+ },
+
+ /**
+ * GET isFullscreen returns true if the video is running in fullscreen mode
+ *
+ * @returns {boolean}
+ */
+ get isFullscreen() {
+ return document.fullscreenElement == document.body;
+ },
+
+ /**
+ * Used for recording telemetry in Picture-in-Picture.
+ *
+ * @param {string} type
+ * The type of PiP event being recorded.
+ * @param {object} args
+ * The data to pass to telemetry when the event is recorded.
+ */
+ recordEvent(type, args) {
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ type,
+ "player",
+ this.id,
+ args
+ );
+ },
+
+ /**
+ * Send a message to PiPChild to adjust the subtitles position
+ * so that subtitles are visible when showing video controls.
+ */
+ showVideoControls() {
+ // offsetParent returns null when the element or any ancestor has display: none
+ // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
+ this.actor.sendAsyncMessage("PictureInPicture:ShowVideoControls", {
+ isFullscreen: this.isFullscreen,
+ isVideoControlsShowing: true,
+ playerBottomControlsDOMRect: this.controlsBottom.getBoundingClientRect(),
+ isScrubberShowing: !!this.scrubber.offsetParent,
+ });
+ },
+
+ /**
+ * Send a message to PiPChild to adjust the subtitles position
+ * so that subtitles take up remaining space when hiding video controls.
+ */
+ hideVideoControls() {
+ this.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", {
+ isFullscreen: this.isFullscreen,
+ isVideoControlsShowing: false,
+ playerBottomControlsDOMRect: null,
+ });
+ },
+
+ /**
+ * Makes the player controls visible.
+ *
+ * @param {Boolean} revealIndefinitely
+ * 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 (!this.isFullscreen) {
+ // revealControls() is called everytime we hover over fullscreen pip window.
+ // Only communicate with pipchild when not in fullscreen mode for performance reasons.
+ this.showVideoControls();
+ }
+
+ if (!revealIndefinitely) {
+ this.showingTimeout = setTimeout(() => {
+ const isHoverOverControlItem = this.controls.querySelector(
+ ".control-item:hover"
+ );
+ if (this.isFullscreen && isHoverOverControlItem) {
+ return;
+ }
+ this.controls.removeAttribute("showing");
+
+ if (
+ !this.isFullscreen &&
+ !this.isCurrentHover &&
+ !this.controls.getAttribute("keying") &&
+ !this.controls.getAttribute("donthide")
+ ) {
+ this.hideVideoControls();
+ }
+ }, 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 {Number} width
+ * The width of the video being played.
+ * @param {Number} height
+ * 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..5b6892d649
--- /dev/null
+++ b/toolkit/components/pictureinpicture/content/player.xhtml
@@ -0,0 +1,119 @@
+<?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"
+ scrolling="false"
+ >
+ <head>
+ <meta charset="utf-8"/>
+ <link rel="stylesheet" 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:command id="View:Fullscreen" oncommand="Player.fullscreenModeToggle(event);"/>
+ </xul:commandset>
+
+ <xul:keyset>
+ <xul:key id="closeShortcut" key="W" modifiers="accel"/>
+ <xul:key id="muteShortcut" key="↓" modifiers="accel"/>
+ <xul:key id="unMuteShortcut" key="↑" modifiers="accel"/>
+#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:key id="fullscreenToggleShortcut" data-l10n-id="pictureinpicture-toggle-fullscreen-shortcut" command="View:Fullscreen"/>
+ </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" dir="ltr">
+ <button id="close"
+ class="control-item control-button tooltip-under-controls" data-l10n-attrs="tooltip"
+#ifdef XP_MACOSX
+ mac="true"
+ tabindex="9"
+#else
+ tabindex="10"
+#endif
+ />
+ <button id="unpip"
+ class="control-item control-button tooltip-under-controls" data-l10n-id="pictureinpicture-unpip-btn" data-l10n-attrs="tooltip"
+#ifdef XP_MACOSX
+ mac="true"
+ tabindex="10"
+#else
+ tabindex="9"
+#endif
+ />
+ <div id="controls-bottom-gradient" class="control-item"></div>
+ <div id="controls-bottom">
+ <div class="controls-bottom-upper">
+ <div class="scrubber-no-drag">
+ <input id="scrubber" class="control-item" min="0" max="1" step=".001" type="range" tabindex="11" hidden="true"/>
+ </div>
+ </div>
+ <div class="controls-bottom-lower">
+ <div class="start-controls">
+ <div id="timestamp" class="control-item" hidden="true"></div>
+ </div>
+ <div class="center-controls">
+ <button id="seekBackward" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-id="pictureinpicture-seekbackward-btn" data-l10n-attrs="tooltip" tabindex="12"></button>
+ <button id="playpause" class="control-item control-button tooltip-over-controls center-tooltip" tabindex="1"
+ data-l10n-id="pictureinpicture-pause-btn" data-l10n-attrs="tooltip"/>
+ <button id="seekForward" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-id="pictureinpicture-seekforward-btn" data-l10n-attrs="tooltip" tabindex="2"></button>
+ </div>
+ <div class="end-controls">
+ <button id="audio" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-attrs="tooltip" tabindex="3"/>
+ <input id="audio-scrubber" class="control-item" min="0" max="1" step=".001" type="range" tabindex="4" hidden="true"/>
+ <button id="closed-caption" class="control-item control-button tooltip-over-controls center-tooltip closed-caption" hidden="true" disabled="true" data-l10n-id="pictureinpicture-subtitles-btn" data-l10n-attrs="tooltip" tabindex="5"></button>
+ <div id="settings" class="hide panel">
+ <fieldset class="box panel-fieldset">
+ <legend class="a11y-only panel-legend" data-l10n-id="pictureinpicture-subtitles-panel-accessible"></legend>
+ <div class="subtitle-grid">
+ <label id="subtitles-toggle-label" data-l10n-id="pictureinpicture-subtitles-label" class="bold" for="subtitles-toggle"></label>
+ <label class="switch">
+ <input id="subtitles-toggle" type="checkbox" tabindex="6" checked=""/>
+ <span class="slider" role="presentation"></span>
+ </label>
+ </div>
+ <div class="grey-line"></div>
+ <fieldset class="font-size-selection panel-fieldset">
+ <legend data-l10n-id="pictureinpicture-font-size-label" class="bold panel-legend"></legend>
+ <div id="font-size-selection-radio-small" class="font-size-selection-radio">
+ <input id="small" type="radio" name="cc-size" tabindex="7"/>
+ <label data-l10n-id="pictureinpicture-font-size-small" for="small"></label>
+ </div>
+ <div id="font-size-selection-radio-medium" class="font-size-selection-radio">
+ <input id="medium" type="radio" name="cc-size" tabindex="7"/>
+ <label data-l10n-id="pictureinpicture-font-size-medium" for="medium"></label>
+ </div>
+ <div id="font-size-selection-radio-large" class="font-size-selection-radio">
+ <input id="large" type="radio" name="cc-size" tabindex="7"/>
+ <label data-l10n-id="pictureinpicture-font-size-large" for="large"></label>
+ </div>
+ </fieldset>
+ </fieldset>
+ <div class="arrow"></div>
+ </div>
+ <button id="fullscreen" class="control-item control-button tooltip-over-controls inline-end-tooltip" hidden="true" data-l10n-attrs="tooltip" tabindex="8"></button>
+ </div>
+ </div>
+ </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..391cc7728a
--- /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" 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..a5e255d4e3
--- /dev/null
+++ b/toolkit/components/pictureinpicture/docs/index.rst
@@ -0,0 +1,385 @@
+.. _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.sys.mjs`` 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.sys.mjs
+========================
+
+This module runs in the parent process, and is also the scope where all ``PictureInPictureParent`` instances reside. ``PictureInPicture.sys.mjs``'s job is to send and receive messages from ``PictureInPictureChild`` instances, and to react appropriately.
+
+Critically, ``PictureInPicture.sys.mjs`` 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.
+
+Site-specific video wrappers
+============================
+
+A site-specific video wrapper allows for the creation of custom scripts that the Picture-in-Picture component can utilize when videos are loaded in specific domains. Currently, some uses of video wrappers include:
+
+* Integration of captions and subtitles support on certain video streaming sites
+* Fixing inconsistent video behaviour when using Picture-in-Picture controls
+* Hiding the Picture-in-Picture toggle for videos on particular areas of a page, given a URL (rather than hiding the toggle for all videos on a page)
+
+``PictureInPictureChildVideoWrapper`` and ``videoWrapperScriptPath``
+--------------------------------------------------------------------
+``PictureInPictureChildVideoWrapper`` is a special class that represents a video wrapper. It is defined in ``PictureInPictureChild.sys.mjs`` and maps to a ``videoWrapperScriptPath``, which is the path of the custom wrapper script to use.
+``videoWrapperScriptPath`` is defined in `browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js>`_ for a domain,
+and custom wrapper scripts are defined in `browser/extensions/pictureinpicture/video-wrappers <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/video-wrappers>`_.
+
+If a ``videoWrapperScriptPath`` is detected while initializing the Picture-in-Picture toggle or window, we immediately create a new instance of ``PictureInPictureChildVideoWrapper`` based on the given path, allowing us to run our custom scripts.
+
+API
+^^^
+See the full list of methods at `API References <#toolkit-actors-pictureinpicturechild-jsm>`_.
+
+Sandbox
+^^^^^^^
+Performing video control operations on the originating video requires executing code in the browser content. For security reasons, we utilize a *sandbox* to isolate these operations and prevent direct access to ``PictureInPictureChild``. In other words, we run content code within the sandbox itself.
+However, it is necessary to waive :ref:`xray vision <Waiving_Xray_vision>` so that we can execute the video control operations. This is done by reading the wrapper’s ``.wrappedJSObject`` property.
+
+Adding a new site-specific video wrapper
+----------------------------------------
+Creating a new wrapper script file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Add a new JS file for the new video wrapper in `browser/extensions/pictureinpicture/video-wrappers <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/video-wrappers>`_.
+The file must meet several requirements to get the wrapper working.
+
+**Script file requirements**:
+
+* Defined class ``PictureInPictureVideoWrapper``
+* Assigned ``this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper``
+
+**PictureInPictureVideoWrapper class requirements**:
+
+* Implementation of at least one overridable method (see :ref:`picture_in_picture_child_video_wrapper_api`)
+
+**Overriden method requirements**:
+
+* Return value with a type that corresponds to ``validateRetVal`` in ``PictureInPictureChildVideoWrapper.#callWrapperMethod()``
+
+Below is an example of a script file ``mock-wrapper.js`` that overrides an existing method ``setMuted()`` in ``PictureInPictureChildVideoWrapper``:
+
+.. code-block:: js
+
+ // sample file `mock-wrapper.js`
+ class PictureInPictureVideoWrapper {
+ setMuted(video, shouldMute) {
+ if (video.muted !== shouldMute) {
+ let muteButton = document.querySelector("#player .mute-button");
+ if (muteButton) {
+ muteButton.click();
+ } else {
+ video.muted = shouldMute;
+ }
+ }
+ }
+ }
+
+ this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper
+
+.. note::
+ If a new ``PictureInPictureChildVideoWrapper`` video control method is needed, see `Adding a new video control method`_.
+
+Declaring ``videoWrapperScriptPath``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Declare a property ``videoWrapperScriptPath`` for the site at `browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js>`_:
+
+.. code-block:: js
+
+ someWebsite: {
+ "https://*.somewebsite.com/*": {
+ videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
+ },
+ }
+
+In this example, the URL pattern ``https://*.somewebsite.com/*`` is provided for a site named ``someWebsite``.
+Picture-in-Picture checks for any overrides upon initialization, and it will load scripts specified by ``videoWrapperScriptPath``.
+The scripts located at ``video-wrappers/mock-wrapper.js`` will therefore run whenever we view a video from a URL matching ``somewebsite.com``.
+
+Registering the new wrapper in ``moz.build``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+We should update `browser/extensions/pictureinpicture/moz.build <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/moz.build>`_ by adding the path of the newly created wrapper:
+
+.. code-block:: js
+
+ FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += [
+ "video-wrappers/mock-wrapper.js",
+ "video-wrappers/netflix.js",
+ "video-wrappers/youtube.js",
+ ]
+
+As expected for any ``moz.build`` file, order matters. Registered paths should be listed in alphabetical order. Otherwise, the build will fail.
+
+Adding a new video control method
+---------------------------------
+If none of the existing overridable methods in ``PictureInPictureChildVideoWrapper`` are applicable for a bug fix or feature enhancement,
+we can create a new one by calling ``#callWrapperMethod()``. Below is an example of how we would define a new overridable method ``setMuted()``:
+
+.. code-block:: js
+
+ // class PictureInPictureChildVideoWrapper in PictureInPictureChild.sys.mjs
+ setMuted(video, shouldMute) {
+ return this.#callWrapperMethod({
+ name: "setMuted",
+ args: [video, shouldMute],
+ fallback: () => {
+ video.muted = shouldMute;
+ },
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+The new method passes to ``#callWrapperMethod()``:
+
+#. The method name
+#. The expected arguments that a wrapper script may use
+#. A fallback function
+#. A conditional expression that validates the return value
+
+The fallback function only executes if a wrapper script fails or if the method is not overriden.
+``validateRetVal`` checks the type of the return value and ensures it matches the expected type. If there is no return value, simply validate if type is ``null``.
+
+.. note::
+ Generic method names are preferred so that they can be used for any video wrapper.
+ For example: instead of naming a method ``updateCaptionsContainerForSiteA()``, use ``updateCaptionsContainer()``.
+
+Using the new video control method
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Once the new method is defined, it can be used throughout ``PictureInPictureChild.sys.mjs``. In the current example, we call
+``PictureInPictureChildVideoWrapper.setMuted()`` to mute or unmute a video. ``this.videoWrapper`` is an instance of
+``PictureInPictureChildVideoWrapper``:
+
+.. code-block:: js
+
+ // class PictureInPictureChild in PictureInPictureChild.sys.mjs
+ mute() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.setMuted(video, true);
+ }
+ }
+
+ unmute() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.setMuted(video, false);
+ }
+ }
+
+Testing site-specific video wrappers
+------------------------------------
+Automated Tests
+^^^^^^^^^^^^^^^
+Automated tests for site specific wrappers are currently limited. New tests can be made in `browser/extensions/pictureinpicture/tests/browser <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/tests/browser>`_ to ensure
+general functionality, but these are restricted to Firefox Nightly and do not test functionality on specific sites.
+
+Some challenges with writing tests include:
+
+* Accessing DRM content
+* Log-in credentials if a site requires a user account
+* Detecting modifications to a web page or video player that render a wrapper script obsolete
+
+Manual Tests
+^^^^^^^^^^^^
+The go-to approach right now is to test video wrappers manually, in tandem with reviews provided by the phabricator group `#pip-reviewers <https://phabricator.services.mozilla.com/project/profile/163/>`_. Below are some questions that reviewers will consider:
+
+* Does Picture-in-Picture crash or freeze?
+* Does the wrapper work on Windows, MacOS, and Linux?
+* Do Picture-in-Picture features work as expected? (Picture-in-Picture toggle, text tracks, video controls, etc.)
+* Do existing automated tests work as they should?
+
+.. warning::
+ DRM content may not load for all local Firefox builds. One possible solution is to test the video wrapper in a try build (ex. Linux).
+ Depending on the changes made, we may also require the script to run under a temporary pref such as ``media.videocontrols.picture-in-picture.WIP.someWebsiteWrapper`` for the purpose of testing changes in Firefox Nightly.
+
+API References
+==============
+``toolkit/components/pictureinpicture``
+---------------------------------------
+.. toctree::
+ :maxdepth: 1
+
+ picture-in-picture-api
+ player-api
+
+``toolkit/actors/PictureInPictureChild.sys.mjs``
+------------------------------------------------
+* :ref:`picture_in_picture_child_video_wrapper_api`
diff --git a/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst b/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst
new file mode 100644
index 0000000000..06f2ea5fc4
--- /dev/null
+++ b/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst
@@ -0,0 +1,4 @@
+PictureInPicture Reference
+===========================
+.. js:autoclass:: PictureInPicture
+ :members:
diff --git a/toolkit/components/pictureinpicture/docs/player-api.rst b/toolkit/components/pictureinpicture/docs/player-api.rst
new file mode 100644
index 0000000000..a990967dc1
--- /dev/null
+++ b/toolkit/components/pictureinpicture/docs/player-api.rst
@@ -0,0 +1,4 @@
+Player Reference
+======================
+.. js:autoclass:: Player
+ :members:
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..e6ca96b903
--- /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", "Picture-in-Picture")
+
+SPHINX_TREES["pictureinpicture"] = "docs"
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "PictureInPicture.sys.mjs",
+ "PictureInPictureControls.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "tests/browser.toml",
+]
diff --git a/toolkit/components/pictureinpicture/tests/browser.toml b/toolkit/components/pictureinpicture/tests/browser.toml
new file mode 100644
index 0000000000..f28a105698
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser.toml
@@ -0,0 +1,239 @@
+[DEFAULT]
+support-files = [
+ "click-event-helper.js",
+ "head.js",
+ "short.mp4",
+ "no-audio-track.webm",
+ "test-button-overlay.html",
+ "test-media-stream.html",
+ "test-opaque-overlay.html",
+ "test-page.html",
+ "test-page-without-audio.html",
+ "test-page-multiple-contexts.html",
+ "test-page-pipDisabled.html",
+ "test-page-with-iframe.html",
+ "test-page-with-sound.html",
+ "test-page-with-webvtt.html",
+ "test-pointer-events-none.html",
+ "test-reversed.html",
+ "test-transparent-nested-iframes.html",
+ "test-transparent-overlay-1.html",
+ "test-transparent-overlay-2.html",
+ "test-video.mp4",
+ "test-video-cropped.mp4",
+ "test-video-long.mp4",
+ "test-video-selection.html",
+ "test-video-vertical.mp4",
+ "test-webvtt-1.vtt",
+ "test-webvtt-2.vtt",
+ "test-webvtt-3.vtt",
+ "test-webvtt-4.vtt",
+ "test-webvtt-5.vtt",
+ "../../../../dom/media/test/gizmo.mp4",
+ "../../../../dom/media/test/owl.mp3",
+]
+
+prefs = [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled=false",
+ "media.videocontrols.picture-in-picture.enabled=true",
+ "media.videocontrols.picture-in-picture.video-toggle.always-show=true",
+ "media.videocontrols.picture-in-picture.video-toggle.enabled=true",
+ "media.videocontrols.picture-in-picture.video-toggle.has-used=true",
+ "media.videocontrols.picture-in-picture.video-toggle.position=\"right\"",
+ "media.videocontrols.picture-in-picture.video-toggle.testing=true",
+ "media.videocontrols.picture-in-picture.urlbar-button.enabled=true",
+]
+
+["browser_aaa_run_first_firstTimePiPToggleEvents.js"]
+
+["browser_aaa_telemetry_togglePiP.js"]
+
+["browser_audioScrubber.js"]
+
+["browser_backgroundTab.js"]
+
+["browser_cannotTriggerFromContent.js"]
+
+["browser_changePiPSrcInFullscreen.js"]
+
+["browser_closePipPause.js"]
+
+["browser_closePip_pageNavigationChanges.js"]
+
+["browser_closePlayer.js"]
+
+["browser_closeTab.js"]
+
+["browser_close_unpip_focus.js"]
+
+["browser_conflictingPips.js"]
+
+["browser_contextMenu.js"]
+skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1569205
+
+["browser_controlsHover.js"]
+
+["browser_cornerSnapping.js"]
+run-if = ["os == 'mac'"]
+
+["browser_dblclickFullscreen.js"]
+
+["browser_disableSwipeGestures.js"]
+skip-if = ["os == 'mac'"] # Bug 1840716
+
+["browser_durationChange.js"]
+
+["browser_flipIconWithRTL.js"]
+skip-if = [
+ "os == 'linux' && ccov", # Bug 1678091
+ "tsan", # Bug 1678091
+]
+
+["browser_fontSize_change.js"]
+
+["browser_fullscreen.js"]
+skip-if = [
+ "os == 'mac' && debug", # Bug 1566173
+ "os == 'linux'", # Bug 1664667
+]
+
+["browser_improved_controls.js"]
+
+["browser_keyboardClosePIPwithESC.js"]
+
+["browser_keyboardFullScreenPIPShortcut.js"]
+
+["browser_keyboardShortcut.js"]
+
+["browser_keyboardShortcutClosePIP.js"]
+
+["browser_keyboardShortcutWithNanDuration.js"]
+support-files = ["test-page-with-nan-video-duration.html"]
+
+["browser_keyboardToggle.js"]
+
+["browser_mediaStreamVideos.js"]
+
+["browser_mouseButtonVariation.js"]
+skip-if = [
+ "debug",
+ "os == 'linux' && bits == 64 && !debug", # Bug 1549875
+]
+
+["browser_multiPip.js"]
+
+["browser_nimbusDisplayDuration.js"]
+
+["browser_nimbusFirstTimeStyleVariant.js"]
+
+["browser_nimbusMessageFirstTimePip.js"]
+
+["browser_nimbusShowIconOnly.js"]
+
+["browser_noPlayerControlsOnMiddleRightClick.js"]
+
+["browser_noToggleOnAudio.js"]
+
+["browser_occluded_window.js"]
+
+["browser_playerControls.js"]
+
+["browser_preserveTabPipIconOverlay.js"]
+
+["browser_privateWindow.js"]
+
+["browser_removeVideoElement.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_subtitles_settings_panel.js"]
+
+["browser_tabIconOverlayPiP.js"]
+
+["browser_telemetry_enhancements.js"]
+
+["browser_text_tracks_webvtt_1.js"]
+
+["browser_text_tracks_webvtt_2.js"]
+
+["browser_text_tracks_webvtt_3.js"]
+
+["browser_thirdPartyIframe.js"]
+
+["browser_toggleAfterTabTearOutIn.js"]
+skip-if = [
+ "os == 'linux' && bits == 64", # Bug 1605546
+ "os == 'mac' && !debug", # Bug 1605546
+]
+
+["browser_toggleButtonOnNanDuration.js"]
+skip-if = ["os == 'linux' && !debug"] # Bug 1700504
+support-files = ["test-page-with-nan-video-duration.html"]
+
+["browser_toggleButtonOverlay.js"]
+skip-if = ["true"] # Bug 1546455
+
+["browser_toggleMode_2.js"]
+skip-if = [
+ "os == 'linux'", # Bug 1654971
+ "os == 'mac'", # 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
+ "apple_catalina && debug", # Bug 1605565
+]
+
+["browser_togglePositionChange.js"]
+skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1738532
+
+["browser_toggleSimple.js"]
+skip-if = ["os == 'linux'"] # Bug 1546455
+
+["browser_toggleTransparentOverlay-1.js"]
+skip-if = ["os == 'linux' && bits == 64"] # Bug 1552288
+
+["browser_toggleTransparentOverlay-2.js"]
+skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1546930
+
+["browser_toggle_enabled.js"]
+
+["browser_toggle_videocontrols.js"]
+
+["browser_toggle_without_audio.js"]
+
+["browser_touch_toggle_enablepip.js"]
+
+["browser_urlbar_toggle.js"]
+
+["browser_videoEmptied.js"]
+
+["browser_videoSelection.js"]
diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js
new file mode 100644
index 0000000000..df6261584c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js
@@ -0,0 +1,314 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const FIRST_TIME_PIP_TOGGLE_STYLES = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [],
+ },
+ },
+};
+
+const FIRST_CONTEXT_MENU_EXPECTED_EVENTS = [
+ [
+ "pictureinpicture",
+ "opened_method",
+ "contextMenu",
+ null,
+ { firstTimeToggle: "true" },
+ ],
+];
+
+const SECOND_CONTEXT_MENU_EXPECTED_EVENTS = [
+ [
+ "pictureinpicture",
+ "opened_method",
+ "contextMenu",
+ null,
+ { firstTimeToggle: "false" },
+ ],
+];
+
+const FIRST_TOGGLE_EXPECTED_EVENTS = [
+ ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "true" }],
+ [
+ "pictureinpicture",
+ "opened_method",
+ "toggle",
+ null,
+ { firstTimeToggle: "true" },
+ ],
+];
+
+const SECOND_TOGGLE_EXPECTED_EVENTS = [
+ ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "false" }],
+ [
+ "pictureinpicture",
+ "opened_method",
+ "toggle",
+ null,
+ { firstTimeToggle: "false" },
+ ],
+];
+
+/**
+ * This function will open the PiP window by clicking the toggle
+ * and then close the PiP window
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function openAndClosePipWithToggle(browser, videoID) {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await prepareForToggleClick(browser, videoID);
+
+ await clearAllContentEvents();
+
+ // 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",
+ FIRST_TIME_PIP_TOGGLE_STYLES
+ );
+
+ 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 assertSawClickEventOnly(browser);
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser);
+ await assertSawMouseEvents(browser, true);
+}
+
+/**
+ * This function will open the PiP window by with the context menu
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function openAndClosePipWithContextMenu(browser, videoID) {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown");
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "contextmenu",
+ },
+ browser
+ );
+
+ await popupshown;
+ let isContextMenuOpen = menu.state === "showing" || menu.state === "open";
+ ok(isContextMenuOpen, "Context menu is open");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ // clear content events
+ await clearAllContentEvents();
+
+ let hidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden");
+ menu.activateItem(menu.querySelector("#context-video-pictureinpicture"));
+ await hidden;
+
+ 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);
+}
+
+async function clearAllContentEvents() {
+ // Clear everything.
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ });
+}
+
+add_task(async function test_eventTelemetry() {
+ Services.telemetry.clearEvents();
+ await clearAllContentEvents();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ Services.telemetry.setEventRecordingEnabled("pictureinpicture", true);
+ let videoID = "no-controls";
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ // open with context menu for first time
+ await openAndClosePipWithContextMenu(browser, videoID);
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "contextMenu",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_CONTEXT_MENU_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FIRST_CONTEXT_MENU_EXPECTED_EVENTS,
+ filter,
+ { clear: true, process: "content" }
+ );
+
+ // open with toggle for first time
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ await openAndClosePipWithToggle(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_TOGGLE_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(FIRST_TOGGLE_EXPECTED_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ // open with toggle for not first time
+ await openAndClosePipWithToggle(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ SECOND_TOGGLE_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(SECOND_TOGGLE_EXPECTED_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ // open with context menu for not first time
+ await openAndClosePipWithContextMenu(browser, videoID);
+
+ filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "contextMenu",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ SECOND_CONTEXT_MENU_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ SECOND_CONTEXT_MENU_EXPECTED_EVENTS,
+ filter,
+ { true: false, process: "content" }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js
new file mode 100644
index 0000000000..d6a7540e15
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function getTelemetryToggleEnabled() {
+ const scalarData = Services.telemetry.getSnapshotForScalars(
+ "main",
+ false
+ ).parent;
+ return scalarData["pictureinpicture.toggle_enabled"];
+}
+
+/**
+ * Tests telemetry for user toggling on or off PiP.
+ */
+add_task(async () => {
+ const TOGGLE_PIP_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_PIP_ENABLED_PREF, true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let contextPiPDisable = document.getElementById(
+ "context_HidePictureInPictureToggle"
+ );
+ contextPiPDisable.click();
+ const enabled = Services.prefs.getBoolPref(
+ TOGGLE_PIP_ENABLED_PREF,
+ false
+ );
+
+ Assert.equal(enabled, false, "PiP is disabled.");
+
+ await TestUtils.waitForCondition(() => {
+ return getTelemetryToggleEnabled() === false;
+ });
+
+ Assert.equal(
+ getTelemetryToggleEnabled(),
+ false,
+ "PiP is disabled according to Telemetry."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_PIP_ENABLED_PREF, true]],
+ });
+
+ await TestUtils.waitForCondition(() => {
+ return getTelemetryToggleEnabled() === true;
+ });
+
+ Assert.equal(
+ getTelemetryToggleEnabled(),
+ true,
+ "PiP is enabled according to Telemetry."
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js
new file mode 100644
index 0000000000..d13d0d13ad
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js
@@ -0,0 +1,262 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const IMPROVED_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
+
+const videoID = "with-controls";
+
+async function getVideoVolume(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).volume;
+ });
+}
+
+async function getVideoMuted(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+}
+
+add_task(async function test_audioScrubber() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let ratio = pipWin.innerWidth / pipWin.innerHeight;
+ let height = 750;
+ let width = Math.floor(ratio * height);
+ if (pipWin.innerHeight < height || pipWin.innerWidth < width) {
+ // Resize the PiP window so we know the audio scrubber is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+ }
+
+ let audioScrubber = pipWin.document.getElementById("audio-scrubber");
+ audioScrubber.focus();
+
+ // Volume should be 1 and video should not be muted when opening this video
+ let actualVolume = await getVideoVolume(browser, videoID);
+ let expectedVolume = 1;
+
+ let actualMuted = await getVideoMuted(browser, videoID);
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(!actualMuted, "Video is not muted");
+
+ let volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that left arrow key changes volume by -0.1
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.9;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test that right arrow key changes volume by +0.1 and does not exceed 1
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 1;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test that the mouse can move the audio scrubber to 0.5
+ let rect = audioScrubber.getBoundingClientRect();
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ rect.width / 2,
+ rect.height / 2,
+ {},
+ pipWin
+ );
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.5;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ 0.01,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ // Test muting and unmuting the video
+ let muteButton = pipWin.document.getElementById("audio");
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+ muteButton.click();
+ await volumeChangePromise;
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "The video is muted aftering clicking the mute button"
+ );
+ is(
+ audioScrubber.max,
+ "0",
+ "The max of the audio scrubber is 0, so the volume slider appears that the volume is 0"
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+ muteButton.click();
+ await volumeChangePromise;
+
+ ok(
+ !(await getVideoMuted(browser, videoID)),
+ "The video is muted aftering clicking the mute button"
+ );
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ 0.01,
+ `The volume is still ${actualVolume}.`
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that the audio scrubber can be dragged from 0.5 to 0 and the video gets muted
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ rect.width / 2,
+ rect.height / 2,
+ { type: "mousedown" },
+ pipWin
+ );
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ 0,
+ rect.height / 2,
+ { type: "mousemove" },
+ pipWin
+ );
+ EventUtils.synthesizeMouse(
+ audioScrubber,
+ 0,
+ rect.height / 2,
+ { type: "mouseup" },
+ pipWin
+ );
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "Video is now muted because slider was moved to 0"
+ );
+
+ // Test that the left arrow key does not unmute the video and the volume remains at 0
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ actualVolume = await getVideoVolume(browser, videoID);
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ await getVideoMuted(browser, videoID),
+ "Video is now muted because slider is still at 0"
+ );
+
+ volumeChangePromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "volumechange",
+ true
+ );
+
+ // Test that the right arrow key will increase the volume by +0.1 and will unmute the video
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ await volumeChangePromise;
+
+ actualVolume = await getVideoVolume(browser, videoID);
+ expectedVolume = 0.1;
+
+ isfuzzy(
+ actualVolume,
+ expectedVolume,
+ Number.EPSILON,
+ `The actual volume ${actualVolume}. The expected volume ${expectedVolume}`
+ );
+
+ ok(
+ !(await getVideoMuted(browser, videoID)),
+ "Video is no longer muted because we moved the slider"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js
new file mode 100644
index 0000000000..e1f96748a5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+/**
+ * This test creates a PiP window, then switches to another tab and confirms
+ * that the PiP tab is still active.
+ */
+add_task(async () => {
+ let videoID = "no-controls";
+ let firstTab = gBrowser.selectedTab;
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let originatingTab = gBrowser.getTabForBrowser(browser);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+
+ let switcher = gBrowser._getSwitcher();
+
+ Assert.equal(
+ switcher.getTabState(originatingTab),
+ switcher.STATE_LOADED,
+ "The originating browser tab should be in STATE_LOADED."
+ );
+ Assert.equal(
+ browser.docShellIsActive,
+ true,
+ "The docshell should be active in the originating tab"
+ );
+
+ // We need to destroy the current AsyncTabSwitcher to avoid
+ // tabrowser.shouldActivateDocShell going in the
+ // AsyncTabSwitcher.shouldActivateDocShell code path which isn't PiP aware.
+ switcher.destroy();
+
+ // Closing with window.close doesn't actually pause the video, so click
+ // the close button instead.
+ pipWin.document.getElementById("close").click();
+ await BrowserTestUtils.windowClosed(pipWin);
+
+ Assert.equal(
+ browser.docShellIsActive,
+ false,
+ "The docshell should be inactive in the originating tab"
+ );
+ }
+ );
+});
+
+/**
+ * This test creates a PiP window, then minimizes the browser and confirms
+ * that the PiP tab is still active.
+ */
+add_task(async () => {
+ let videoID = "no-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let originatingTab = gBrowser.getTabForBrowser(browser);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+
+ let switcher = gBrowser._getSwitcher();
+
+ Assert.equal(
+ switcher.getTabState(originatingTab),
+ switcher.STATE_LOADED,
+ "The originating browser tab should be in STATE_LOADED."
+ );
+
+ await BrowserTestUtils.closeWindow(pipWin);
+
+ // Workaround bug 1782134.
+ window.restore();
+ }
+ );
+});
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_changePiPSrcInFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js
new file mode 100644
index 0000000000..e971f17296
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js
@@ -0,0 +1,511 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const NEW_VIDEO_ASPECT_RATIO = 1.334;
+
+async function switchVideoSource(browser, src) {
+ await ContentTask.spawn(browser, { src }, async ({ src }) => {
+ let doc = content.document;
+ let video = doc.getElementById("no-controls");
+ video.src = src;
+ });
+}
+
+/**
+ *
+ * @param {Object} actual The actual size and position of the window
+ * @param {Object} expected The expected size and position of the window
+ * @param {String} message A message to print before asserting the size and position
+ */
+function assertEvent(actual, expected, message) {
+ info(message);
+ isfuzzy(
+ actual.width,
+ expected.width,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual width: ${actual.width}. The expected width: ${expected.width}`
+ );
+ isfuzzy(
+ actual.height,
+ expected.height,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual height: ${actual.height}. The expected height: ${expected.height}`
+ );
+ isfuzzy(
+ actual.left,
+ expected.left,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual left: ${actual.left}. The expected left: ${expected.left}`
+ );
+ isfuzzy(
+ actual.top,
+ expected.top,
+ ACCEPTABLE_DIFFERENCE,
+ `The actual top: ${actual.top}. The expected top: ${expected.top}`
+ );
+}
+
+/**
+ * This test is our control test. This tests that when the PiP window exits
+ * fullscreen it will return to the size it was before being fullscreened.
+ */
+add_task(async function testNoSrcChangeFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * This function tests changing the src of a Picture-in-Picture player while
+ * the player is fullscreened and then ensuring the that video stays
+ * fullscreened after the src change and that the player will resize to the new
+ * video size.
+ */
+add_task(async function testChangingSameSizeVideoSrcFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+ let sandbox = sinon.createSandbox();
+ let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo");
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ await switchVideoSource(browser, "test-video.mp4");
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeToVideoSpy.calledOnce,
+ "Waiting for deferredResize to be updated"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Double-click caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+
+ sandbox.restore();
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * This is similar to the previous test but in this test we switch to a video
+ * with a different aspect ratio to confirm that the PiP window will take the
+ * new aspect ratio after exiting fullscreen. We also exit fullscreen with the
+ * escape key instead of double clicking in this test.
+ */
+add_task(async function testChangingDifferentSizeVideoSrcFullscreen() {
+ // After opening the PiP window, it is resized to 640x360. There is a change
+ // the PiP window will open with that size. To prevent that we override the
+ // last saved position so we open at (0, 0) and 300x300.
+ overrideSavedPosition(0, 0, 300, 300);
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let pipWin = await triggerPictureInPicture(browser, "no-controls");
+ let controls = pipWin.document.getElementById("controls");
+ const screen = pipWin.screen;
+ let sandbox = sinon.createSandbox();
+ let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo");
+
+ let resizeEventArray = [];
+ pipWin.addEventListener("resize", event => {
+ let win = event.target;
+ let obj = {
+ width: win.innerWidth,
+ height: win.innerHeight,
+ left: win.screenLeft,
+ top: win.screenTop,
+ };
+ resizeEventArray.push(obj);
+ });
+
+ // Move the PiP window to an unsaved location
+ let left = 100;
+ let top = 100;
+ pipWin.moveTo(left, top);
+
+ await BrowserTestUtils.waitForCondition(
+ () => pipWin.screenLeft === 100 && pipWin.screenTop === 100,
+ "Waiting for PiP to move to 100, 100"
+ );
+
+ let width = 640;
+ let height = 360;
+
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(width, height);
+ await resizePromise;
+
+ Assert.equal(
+ resizeEventArray.length,
+ 1,
+ "resizeEventArray should have 1 event"
+ );
+
+ let actualEvent = resizeEventArray.splice(0, 1)[0];
+ let expectedEvent = { width, height, left, top };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned before fullscreen"
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: screen.width,
+ height: screen.height,
+ left: screen.left,
+ top: screen.top,
+ };
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly fullscreened before switching source"
+ );
+
+ let previousWidth = pipWin.getDeferredResize().width;
+
+ await switchVideoSource(browser, "test-video-long.mp4");
+
+ // Confirm that we are updating the `deferredResize` and not actually resizing
+ await BrowserTestUtils.waitForCondition(
+ () => resizeToVideoSpy.calledOnce,
+ "Waiting for deferredResize to be updated"
+ );
+
+ // Confirm that we updated the deferredResize to the new width
+ await BrowserTestUtils.waitForCondition(
+ () => previousWidth !== pipWin.getDeferredResize().width,
+ "Waiting for deferredResize to update"
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ Assert.ok(
+ !pipWin.document.fullscreenElement,
+ "Escape key caused us to exit fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length >= 1,
+ "Waiting for resizeEventArray to have 1 event, got " +
+ resizeEventArray.length
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+ expectedEvent = {
+ width: height * NEW_VIDEO_ASPECT_RATIO,
+ height,
+ left,
+ top,
+ };
+
+ // When two resize events happen very close together we optimize by
+ // "coalescing" the two resizes into a single resize event. Sometimes
+ // the events aren't "coalesced" together (I don't know why) so I check
+ // if the most recent event is what we are looking for and if it is not
+ // then I'll wait for the resize event we are looking for.
+ if (
+ Math.abs(
+ actualEvent.width - expectedEvent.width <= ACCEPTABLE_DIFFERENCE
+ )
+ ) {
+ // The exit fullscreen resize events were "coalesced".
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+ } else {
+ // For some reason the exit fullscreen resize events weren't "coalesced"
+ // so we have to wait for the next resize event.
+ await BrowserTestUtils.waitForCondition(
+ () => resizeEventArray.length === 1,
+ "Waiting for resizeEventArray to have 1 event"
+ );
+
+ actualEvent = resizeEventArray.splice(0, 1)[0];
+
+ assertEvent(
+ actualEvent,
+ expectedEvent,
+ "The PiP window has been correctly positioned after exiting fullscreen"
+ );
+ }
+
+ sandbox.restore();
+ await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false);
+
+ clearSavedPosition();
+ }
+ );
+});
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_closePip_pageNavigationChanges.js b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js
new file mode 100644
index 0000000000..c64b5f2b14
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the pip window closes when the pagehide page lifecycle event
+ * is not detected and if a video is not loaded with a src.
+ */
+add_task(async function test_close_empty_pip_window() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+
+ let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Waiting for emptied event to be called");
+ await ContentTaskUtils.waitForEvent(video, "emptied");
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.removeAttribute("src");
+ video.load();
+ });
+ await emptied;
+ await pipClosed;
+ }
+ );
+});
+
+/**
+ * Tests that the pip window closes when navigating to another page
+ * via the pagehide page lifecycle event.
+ */
+add_task(async function test_close_pagehide() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.onemptied = () => {
+ // Since we handle pagehide first, handle emptied should not be invoked
+ ok(false, "emptied not expected to be called");
+ };
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ content.location.href = "otherpage.html";
+ });
+
+ await pipClosed;
+ }
+ );
+});
+
+/**
+ * Tests that the pip window remains open if the pagehide page lifecycle
+ * event is not detected and if the video is still loaded with a src.
+ */
+add_task(async function test_open_pip_window_history_nav() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let popStatePromise = ContentTaskUtils.waitForEvent(
+ content,
+ "popstate"
+ );
+ content.history.pushState({}, "new page", "test-page-with-sound.html");
+ content.history.back();
+ await popStatePromise;
+ });
+
+ ok(!pipWin.closed, "pip windows should still be open");
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ }
+ );
+});
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_close_unpip_focus.js b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js
new file mode 100644
index 0000000000..f535add96a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that closing a pip window will not focus on the originating video's window.
+add_task(async function test_close_button_focus() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // Open PiP
+ let videoID = "with-controls";
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let focus = BrowserTestUtils.waitForEvent(win2, "focus", true);
+ win2.focus();
+ await focus;
+
+ // Close PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ let oldFocus = win1.focus;
+ win1.focus = () => {
+ ok(false, "Window is not supposed to be focused on");
+ };
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ ok(true, "Window did not get focus");
+
+ win1.focus = oldFocus;
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+// Tests that pressing the unpip button will focus on the originating video's window.
+add_task(async function test_unpip_button_focus() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ // Open PiP
+ let videoID = "with-controls";
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let focus = BrowserTestUtils.waitForEvent(win2, "focus", true);
+ win2.focus();
+ await focus;
+
+ // Close PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("unpip");
+ let pipWinFocusedPromise = BrowserTestUtils.waitForEvent(win1, "focus", true);
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+
+ await pipClosed;
+ await pipWinFocusedPromise;
+ ok(true, "Originating window got focus");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js
new file mode 100644
index 0000000000..cf9e27e327
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * If multiple PiPs try to open in the same place, they should not overlap.
+ */
+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");
+
+ await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false);
+ info("Closed first PiP to save location");
+
+ let secondPip = await triggerPictureInPicture(browser, "with-controls");
+ ok(secondPip, "Got second PiP window");
+
+ let thirdPip = await triggerPictureInPicture(browser, "no-controls");
+ ok(thirdPip, "Got third PiP window");
+
+ Assert.ok(
+ secondPip.screenX != thirdPip.screenX ||
+ secondPip.screenY != thirdPip.screenY,
+ "Conflicting PiPs were successfully opened in different locations"
+ );
+
+ await ensureMessageAndClosePiP(
+ browser,
+ "with-controls",
+ secondPip,
+ false
+ );
+ info("Second PiP was still open and is now closed");
+
+ await ensureMessageAndClosePiP(browser, "no-controls", thirdPip, false);
+ info("Third PiP was still open and is now closed");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js
new file mode 100644
index 0000000000..ce6a85e91c
--- /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 => {
+ let contextMenu = 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."
+ );
+
+ contextMenu.activateItem(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.");
+
+ contextMenu = await openContextMenu(browser, videoId);
+
+ info("Context menu is open again.");
+
+ contextMenu.activateItem(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_controlsHover.js b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js
new file mode 100644
index 0000000000..7a3a33733f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js
@@ -0,0 +1,191 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests functionality for the hover states of the various controls for the Picture-in-Picture
+ * video window.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ 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.");
+
+ const l10n = new Localization(
+ ["toolkit/pictureinpicture/pictureinpicture.ftl"],
+ true
+ );
+
+ let [
+ close,
+ play,
+ unmute,
+ unpip,
+ subtitles,
+ pause,
+ mute,
+ fullscreenEnter,
+ fullscreenExit,
+ ] = l10n.formatMessagesSync([
+ {
+ id: "pictureinpicture-close-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("closeShortcut")
+ ),
+ },
+ },
+ { id: "pictureinpicture-play-btn" },
+ {
+ id: "pictureinpicture-unmute-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("unMuteShortcut")
+ ),
+ },
+ },
+ { id: "pictureinpicture-unpip-btn" },
+ { id: "pictureinpicture-subtitles-btn" },
+ { id: "pictureinpicture-pause-btn" },
+ {
+ id: "pictureinpicture-mute-btn",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("muteShortcut")
+ ),
+ },
+ },
+ {
+ id: "pictureinpicture-fullscreen-btn2",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("fullscreenToggleShortcut")
+ ),
+ },
+ },
+ {
+ id: "pictureinpicture-exit-fullscreen-btn2",
+ args: {
+ shortcut: ShortcutUtils.prettifyShortcut(
+ pipWin.document.getElementById("fullscreenToggleShortcut")
+ ),
+ },
+ },
+ ]);
+
+ let closeButton = pipWin.document.getElementById("close");
+ let playPauseButton = pipWin.document.getElementById("playpause");
+ let unpipButton = pipWin.document.getElementById("unpip");
+ let muteUnmuteButton = pipWin.document.getElementById("audio");
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ let fullscreenButton = pipWin.document.getElementById("fullscreen");
+
+ // checks hover title for close button
+ await pipWin.document.l10n.translateFragment(closeButton);
+ Assert.equal(
+ close.attributes[1].value,
+ closeButton.getAttribute("tooltip"),
+ "The close button title matches Fluent string"
+ );
+
+ // checks hover title for play button
+ await pipWin.document.l10n.translateFragment(playPauseButton);
+ Assert.equal(
+ pause.attributes[1].value,
+ playPauseButton.getAttribute("tooltip"),
+ "The play button title matches Fluent string"
+ );
+
+ // checks hover title for unpip button
+ await pipWin.document.l10n.translateFragment(unpipButton);
+ Assert.equal(
+ unpip.attributes[1].value,
+ unpipButton.getAttribute("tooltip"),
+ "The unpip button title matches Fluent string"
+ );
+
+ // checks hover title for subtitles button
+ await pipWin.document.l10n.translateFragment(subtitlesButton);
+ Assert.equal(
+ subtitles.attributes[1].value,
+ subtitlesButton.getAttribute("tooltip"),
+ "The subtitles button title matches Fluent string"
+ );
+
+ // checks hover title for unmute button
+ await pipWin.document.l10n.translateFragment(muteUnmuteButton);
+ Assert.equal(
+ mute.attributes[1].value,
+ muteUnmuteButton.getAttribute("tooltip"),
+ "The Unmute button title matches Fluent string"
+ );
+
+ // Pause the video
+ let pausedPromise = waitForVideoEvent("pause");
+ EventUtils.synthesizeMouseAtCenter(playPauseButton, {}, pipWin);
+ await pausedPromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // checks hover title for pause button
+ await pipWin.document.l10n.translateFragment(playPauseButton);
+ Assert.equal(
+ play.attributes[1].value,
+ playPauseButton.getAttribute("tooltip"),
+ "The pause button title matches Fluent string"
+ );
+
+ // Mute the video
+ let mutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(muteUnmuteButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ // checks hover title for mute button
+ await pipWin.document.l10n.translateFragment(muteUnmuteButton);
+ Assert.equal(
+ unmute.attributes[1].value,
+ muteUnmuteButton.getAttribute("tooltip"),
+ "The mute button title matches Fluent string"
+ );
+
+ // checks hover title for enter fullscreen button
+ await pipWin.document.l10n.translateFragment(fullscreenButton);
+ Assert.equal(
+ fullscreenEnter.attributes[1].value,
+ fullscreenButton.getAttribute("tooltip"),
+ "The enter fullscreen button title matches Fluent string"
+ );
+
+ // enable fullscreen
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.synthesizeMouseAtCenter(fullscreenButton, {}, pipWin);
+ });
+
+ // checks hover title for exit fullscreen button
+ await pipWin.document.l10n.translateFragment(fullscreenButton);
+ Assert.equal(
+ fullscreenExit.attributes[1].value,
+ fullscreenButton.getAttribute("tooltip"),
+ "The exit fullscreen button title matches Fluent string"
+ );
+ }
+ );
+});
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..c30d316fe3
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js
@@ -0,0 +1,101 @@
+/* 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 () => {
+ EventUtils.sendMouseEvent(
+ {
+ type: "dblclick",
+ },
+ controls,
+ pipWin
+ );
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click caused us to enter fullscreen."
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => {
+ let close = pipWin.document.getElementById("close");
+ let opacity = parseFloat(pipWin.getComputedStyle(close).opacity);
+ return opacity == 0.0;
+ },
+ "Close button in player should have reached 0.0 opacity",
+ 100,
+ 100
+ );
+
+ // First, we'll test exiting fullscreen by double-clicking again
+ // on the document body.
+
+ await promiseFullscreenExited(pipWin, async () => {
+ 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 () => {
+ 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_disableSwipeGestures.js b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js
new file mode 100644
index 0000000000..b8eddf33f2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js",
+ this
+);
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, "with-controls");
+
+ let receivedSwipeGestureEvents = false;
+ pipWin.addEventListener("MozSwipeGestureMayStart", () => {
+ receivedSwipeGestureEvents = true;
+ });
+
+ const wheelEventPromise = BrowserTestUtils.waitForEvent(pipWin, "wheel");
+
+ // Try swiping left to right.
+ await panLeftToRightBegin(pipWin, 100, 100, 100);
+ await panLeftToRightEnd(pipWin, 100, 100, 100);
+
+ // Wait a wheel event and a couple of frames to give a chance to receive
+ // the MozSwipeGestureMayStart event.
+ await wheelEventPromise;
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ await new Promise(resolve => requestAnimationFrame(resolve));
+
+ Assert.ok(
+ !receivedSwipeGestureEvents,
+ "No swipe gesture events observed"
+ );
+
+ 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_fontSize_change.js b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js
new file mode 100644
index 0000000000..df0fa4f403
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "with-controls";
+const TEXT_TRACK_FONT_SIZE =
+ "media.videocontrols.picture-in-picture.display-text-tracks.size";
+const ACCEPTABLE_DIFF = 1;
+
+const checkFontSize = (actual, expected, str) => {
+ let fs1 = actual.substring(0, actual.length - 2);
+ let fs2 = expected;
+ let diff = Math.abs(fs1 - fs2);
+ info(`Actual font size: ${fs1}. Expected font size: ${fs2}`);
+ Assert.lessOrEqual(diff, ACCEPTABLE_DIFF, str);
+};
+
+function getFontSize(pipBrowser) {
+ return SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ return content.window.getComputedStyle(textTracks).fontSize;
+ });
+}
+
+function promiseResize(win, width, height) {
+ if (win.outerWidth == width && win.outerHeight == height) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ // More than one "resize" might be received if the window was recently
+ // created.
+ win.addEventListener("resize", () => {
+ if (win.outerWidth == width && win.outerHeight == height) {
+ resolve();
+ }
+ });
+ win.resizeTo(width, height);
+ });
+}
+
+/**
+ * Tests the font size is correct for PiP windows
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE_WITH_WEBVTT,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // move PiP window to 0, 0 so resizing the window doesn't go offscreen
+ pipWin.moveTo(0, 0);
+
+ let width = pipWin.innerWidth;
+ let height = pipWin.innerHeight;
+
+ await promiseResize(pipWin, Math.round(250 * (width / height)), 250);
+
+ width = pipWin.innerWidth;
+ height = pipWin.innerHeight;
+
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ let fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.06 * 10) / 10,
+ "The medium font size is .06 of the PiP window height"
+ );
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to small
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "small"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(fontSize, 14, "The small font size is the minimum 14px");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to large
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "large"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.09 * 10) / 10,
+ "The large font size is .09 of the PiP window height"
+ );
+
+ // resize PiP window to a larger size
+ width = pipWin.innerWidth * 2;
+ height = pipWin.innerHeight * 2;
+ await promiseResize(pipWin, width, height);
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(fontSize, 40, "The large font size is the max of 40px");
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+
+ // change font size to small
+ await SpecialPowers.pushPrefEnv({
+ set: [[TEXT_TRACK_FONT_SIZE, "small"]],
+ });
+
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ pipBrowser = pipWin.document.getElementById("browser");
+
+ fontSize = await getFontSize(pipBrowser);
+ checkFontSize(
+ fontSize,
+ Math.round(height * 0.03 * 10) / 10,
+ "The small font size is .03 of the PiP window height"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
new file mode 100644
index 0000000000..439901c0f3
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js
@@ -0,0 +1,161 @@
+/* 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) {
+ info(`Start test of video fullscreen for video ${videoID}.`);
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [videoID], videoID => {
+ let video = this.content.document.getElementById(videoID);
+ return video.requestFullscreen();
+ });
+ });
+
+ info(`Entered video fullscreen, about to mouseover the video.`);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info(`Mouseover complete.`);
+
+ let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID };
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [args], args => {
+ let { videoID, toggleID } = args;
+ let video = this.content.document.getElementById(videoID);
+ let toggle = video.openOrClosedShadowRoot.getElementById(toggleID);
+ ok(
+ ContentTaskUtils.isHidden(toggle),
+ `Toggle should be hidden in video fullscreen mode for video ${videoID}.`
+ );
+ return this.content.document.exitFullscreen();
+ });
+ });
+
+ info(`Exited video fullscreen.`);
+ }
+ }
+ );
+});
+
+/**
+ * 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 => {
+ info(`Start test of browser fullscreen.`);
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.body.requestFullscreen();
+ });
+ });
+
+ info(`Entered browser fullscreen.`);
+
+ for (let videoID of VIDEOS) {
+ info(`About to mouseover for video ${videoID}.`);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${videoID}`,
+ {
+ type: "mouseover",
+ },
+ browser
+ );
+
+ info(`Mouseover complete.`);
+
+ 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.isHidden(toggle),
+ `Toggle should be hidden in body fullscreen mode for video ${videoID}.`
+ );
+ });
+ }
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.exitFullscreen();
+ });
+ });
+
+ info(`Exited browser fullscreen.`);
+ }
+ );
+});
+
+/**
+ * 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 for video ${videoID}.`);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+
+ // need to focus first, since fullscreen request will be blocked otherwise
+ await SimpleTest.promiseFocus(window);
+
+ await promiseFullscreenEntered(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.body.requestFullscreen();
+ });
+ });
+
+ await pipClosed;
+ ok(
+ pipWin.closed,
+ `Picture-In-Picture successfully closed for video ${videoID}.`
+ );
+
+ await promiseFullscreenExited(window, () => {
+ return SpecialPowers.spawn(browser, [], () => {
+ return this.content.document.exitFullscreen();
+ });
+ });
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_improved_controls.js b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js
new file mode 100644
index 0000000000..deadc8d53b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js
@@ -0,0 +1,303 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html";
+
+const IMPROVED_CONTROLS_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
+
+async function getVideoCurrentTime(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).currentTime;
+ });
+}
+
+async function getVideoDuration(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).duration;
+ });
+}
+
+async function timestampUpdated(timestampEl, expectedTimestamp) {
+ await BrowserTestUtils.waitForMutationCondition(
+ timestampEl,
+ { childList: true },
+ () => {
+ return expectedTimestamp === timestampEl.textContent;
+ }
+ );
+}
+
+function checkTimeCloseEnough(actual, expected, message) {
+ let equal = Math.abs(actual - expected);
+ if (equal <= 0.5) {
+ is(equal <= 0.5, true, message);
+ } else {
+ is(actual, expected, message);
+ }
+}
+
+/**
+ * Tests the functionality of improved Picture-in-picture
+ * playback controls.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ 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();
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let fullscreenButton = pipWin.document.getElementById("fullscreen");
+ let seekForwardButton = pipWin.document.getElementById("seekForward");
+ let seekBackwardButton = pipWin.document.getElementById("seekBackward");
+
+ // Try seek forward button
+ let seekedForwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeMouseAtCenter(seekForwardButton, {}, pipWin);
+ ok(await seekedForwardPromise, "The Forward button triggers");
+
+ // Try seek backward button
+ let seekedBackwardPromise = waitForVideoEvent("seeked");
+ EventUtils.synthesizeMouseAtCenter(seekBackwardButton, {}, pipWin);
+ ok(await seekedBackwardPromise, "The Backward button triggers");
+
+ // The Fullsreen button appears when the pref is enabled and the fullscreen hidden property is set to false
+ Assert.ok(!fullscreenButton.hidden, "The Fullscreen button is visible");
+
+ // The seek Forward button appears when the pref is enabled and the seek forward button hidden property is set to false
+ Assert.ok(!seekForwardButton.hidden, "The Forward button is visible");
+
+ // The seek Backward button appears when the pref is enabled and the seek backward button hidden property is set to false
+ Assert.ok(!seekBackwardButton.hidden, "The Backward button is visible");
+
+ // CLose the PIP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, false]],
+ });
+
+ // Open the video in PiP
+ pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ fullscreenButton = pipWin.document.getElementById("fullscreen");
+ seekForwardButton = pipWin.document.getElementById("seekForward");
+ seekBackwardButton = pipWin.document.getElementById("seekBackward");
+
+ // The Fullsreen button disappears when the pref is disabled and the fullscreen hidden property is set to true
+ Assert.ok(
+ fullscreenButton.hidden,
+ "The Fullscreen button is not visible"
+ );
+
+ // The seek Forward button disappears when the pref is disabled and the seek forward button hidden property is set to true
+ Assert.ok(seekForwardButton.hidden, "The Forward button is not visible");
+
+ // The seek Backward button disappears when the pref is disabled and the seek backward button hidden property is set to true
+ Assert.ok(
+ seekBackwardButton.hidden,
+ "The Backward button is not visible"
+ );
+ }
+ );
+});
+
+/**
+ * Tests the functionality of Picture-in-picture
+ * video scrubber
+ */
+add_task(async function testVideoScrubber() {
+ let videoID = "long";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_LONG,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let scrubber = pipWin.document.getElementById("scrubber");
+ scrubber.focus();
+
+ let currentTime = await getVideoCurrentTime(browser, videoID);
+ let expectedVideoTime = 0;
+ const duration = await getVideoDuration(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 0"
+ );
+
+ let timestampEl = pipWin.document.getElementById("timestamp");
+ let expectedTimestamp = "0:00 / 0:08";
+
+ // Wait for the timestamp to update
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ let actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin);
+
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ expectedVideoTime = 5;
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 5"
+ );
+
+ expectedTimestamp = "0:05 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:05 / 0:08");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin);
+
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ expectedVideoTime = 0;
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 0"
+ );
+
+ expectedTimestamp = "0:00 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08");
+
+ let rect = scrubber.getBoundingClientRect();
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width / 2,
+ rect.height / 2,
+ {},
+ pipWin
+ );
+
+ expectedVideoTime = duration / 2;
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 3.98..."
+ );
+
+ expectedTimestamp = "0:04 / 0:08";
+ await timestampUpdated(timestampEl, expectedTimestamp);
+ actualTimestamp = timestampEl.textContent;
+ is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:04 / 0:08");
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width / 2,
+ rect.height / 2,
+ { type: "mousedown" },
+ pipWin
+ );
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width,
+ rect.height / 2,
+ { type: "mousemove" },
+ pipWin
+ );
+
+ EventUtils.synthesizeMouse(
+ scrubber,
+ rect.width,
+ rect.height / 2,
+ { type: "mouseup" },
+ pipWin
+ );
+
+ expectedVideoTime = duration;
+ currentTime = await getVideoCurrentTime(browser, videoID);
+ checkTimeCloseEnough(
+ currentTime,
+ expectedVideoTime,
+ "Video current time is 7.96..."
+ );
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
+
+/**
+ * Tests the behavior of the scrubber and position/duration indicator for a
+ * video with an invalid/non-finite duration.
+ */
+add_task(async function testInvalidDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const videoID = "nan-duration";
+
+ // This tests skips calling ensureVideosReady, because canplaythrough
+ // will never fire for the NaN duration video.
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]],
+ });
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Both the scrubber and the duration should be hidden.
+ let timestampEl = pipWin.document.getElementById("timestamp");
+ ok(timestampEl.hidden, "Timestamp in the PIP window should be hidden.");
+
+ let scrubberEl = pipWin.document.getElementById("scrubber");
+ ok(
+ scrubberEl.hidden,
+ "Scrubber control in the PIP window should be hidden"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js
new file mode 100644
index 0000000000..14dd3d1b7a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * tests that the ESC key would stop the player and close the PiP player floating window
+ */
+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();
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "The Picture-in-Picture window is not there.");
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is paused, but should not."
+ );
+ ok(
+ !pipWin.document.fullscreenElement,
+ "PiP should not yet be in fullscreen."
+ );
+
+ let controls = pipWin.document.getElementById("controls");
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.sendMouseEvent({ type: "dblclick" }, controls, pipWin);
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "Double-click should have caused to enter fullscreen."
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+ });
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "ESC should have caused to leave fullscreen."
+ );
+ ok(
+ !(await isVideoPaused(browser, videoID)),
+ "The video is paused, but should not."
+ );
+
+ // Try to close the PiP window via the ESC button, since now it is not in fullscreen anymore.
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ // then the PiP should have been closed
+ ok(pipWin.closed, "Picture-in-Picture window is not closed, but should.");
+ // and the video should not be playing anymore
+ ok(
+ await isVideoPaused(browser, videoID),
+ "The video is not paused, but should."
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js
new file mode 100644
index 0000000000..da4191cb0e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.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 F-key would enter and exit full screen mode in PiP for the default locale (en-US).
+ */
+add_task(async () => {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let browser = tab.linkedBrowser;
+ let videoID = "with-controls";
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "The Picture-in-Picture window is there.");
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "PiP should not yet be in fullscreen."
+ );
+
+ await promiseFullscreenEntered(pipWin, async () => {
+ EventUtils.synthesizeKey("f", {}, pipWin);
+ });
+
+ Assert.equal(
+ pipWin.document.fullscreenElement,
+ pipWin.document.body,
+ "F-key should have caused to enter fullscreen."
+ );
+
+ await promiseFullscreenExited(pipWin, async () => {
+ EventUtils.synthesizeKey("f", {}, pipWin);
+ });
+
+ ok(
+ !pipWin.document.fullscreenElement,
+ "F-key should have caused to leave fullscreen."
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
new file mode 100644
index 0000000000..a81d99e18c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const PIP_SHORTCUT_OPEN_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "shortcut",
+ },
+];
+
+const PIP_SHORTCUT_CLOSE_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "shortcut",
+ },
+];
+
+/**
+ * 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 => {
+ Services.telemetry.clearEvents();
+ 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);
+
+ let openFilter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "shortcut",
+ };
+ await waitForTelemeryEvents(
+ openFilter,
+ PIP_SHORTCUT_OPEN_EVENTS.length,
+ "content"
+ );
+ TelemetryTestUtils.assertEvents(PIP_SHORTCUT_OPEN_EVENTS, openFilter, {
+ clear: true,
+ process: "content",
+ });
+
+ // 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.");
+
+ let closeFilter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "shortcut",
+ };
+ await waitForTelemeryEvents(
+ closeFilter,
+ PIP_SHORTCUT_CLOSE_EVENTS.length,
+ "parent"
+ );
+ TelemetryTestUtils.assertEvents(PIP_SHORTCUT_CLOSE_EVENTS, closeFilter, {
+ clear: true,
+ process: "parent",
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js
new file mode 100644
index 0000000000..a85cdd5a63
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that keyboard shortcut ctr + w / cmd + w closing PIP window
+ */
+
+add_task(async function test_pip_close_keyboard_shortcut() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ 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.");
+
+ EventUtils.synthesizeKey("w", { accelKey: true }, pipWin);
+ await BrowserTestUtils.windowClosed(pipWin);
+ ok(await isVideoPaused(browser, VIDEO_ID), "The video is paused");
+ ok(pipWin.closed, "Closed PIP");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js
new file mode 100644
index 0000000000..c812dc3e4e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_pip_keyboard_shortcut_with_nan_video_duration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const VIDEO_ID = "test-video";
+
+ await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for ${videoID}`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ });
+
+ 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.");
+
+ pipWin.close();
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js
new file mode 100644
index 0000000000..a70100de85
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests to ensure that tabbing to the pip button and pressing space works
+ * to open the picture-in-picture window.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let pipWin = await triggerPictureInPicture(browser, videoID, () => {
+ EventUtils.synthesizeKey("KEY_Tab", {}); // play button
+ EventUtils.synthesizeKey("KEY_Tab", {}); // pip button
+ EventUtils.synthesizeKey(" ", {});
+ });
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
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..5744c83496
--- /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 assertSawClickEventOnly(browser);
+
+ 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..2821c0d484
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js
@@ -0,0 +1,225 @@
+/* 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"];
+}
+
+/**
+ * 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_nimbusDisplayDuration.js b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js
new file mode 100644
index 0000000000..a2e7e66b40
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const TOGGLE_HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const TOGGLE_FIRST_SEEN_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs";
+
+/**
+ * This tests that the first-time toggle doesn't change to the icon toggle.
+ */
+add_task(async function test_experiment_control_displayDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.notEqual(firstSeen, 0, "First seen should not be 0");
+ }
+ );
+});
+
+/**
+ * This tests that the first-time toggle changes to the icon toggle
+ * if the displayDuration end date is reached or passed.
+ */
+add_task(async function test_experiment_displayDuration_end_date_was_reached() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 222],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(hasUsed, "has-used is true and toggle is icon");
+ Assert.equal(firstSeen, 222, "First seen should remain unchanged");
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that the first-time toggle does not change to the icon toggle
+ * if the displayDuration end date is not yet reached or passed.
+ */
+add_task(async function test_experiment_displayDuration_end_date_not_reached() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 5,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const currentDateSec = Math.round(Date.now() / 1000);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, currentDateSec],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.equal(
+ firstSeen,
+ currentDateSec,
+ "First seen should remain unchanged"
+ );
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that the toggle does not change to the icon toggle if duration is negative.
+ */
+add_task(async function test_experiment_displayDuration_negative_duration() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: -1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, false],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+
+ Assert.ok(!hasUsed, "has-used is false and toggle is not icon");
+ Assert.notEqual(firstSeen, 0, "First seen should not be 0");
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+/**
+ * This tests that first-seen is only recorded for the first-time toggle.
+ */
+add_task(async function test_experiment_displayDuration_already_icon() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ displayDuration: 1,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_FIRST_SEEN_PREF, 0],
+ [TOGGLE_HAS_USED_PREF, true],
+ ],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF);
+ Assert.equal(firstSeen, 0, "First seen should be 0");
+ }
+ );
+
+ await doExperimentCleanup();
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js
new file mode 100644
index 0000000000..492dc6851d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const EXPERIMENT_CLASS_NAME = "experiment";
+
+/**
+ * This tests that the original PiP toggle design is shown.
+ */
+add_task(async function test_experiment_control_toggle_style() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [EXPERIMENT_CLASS_NAME],
+ async EXPERIMENT_CLASS_NAME => {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ let controlsContainer =
+ shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.ok(
+ !controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME)
+ );
+ Assert.ok(!pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME));
+ Assert.ok(
+ ContentTaskUtils.isVisible(pipExplainer),
+ "The PiP message should be visible on the toggle"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the variant PiP toggle design is shown if Nimbus
+ * variable `oldToggle` is false.
+ */
+add_task(async function test_experiment_toggle_style() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ oldToggle: false,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await doExperimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [EXPERIMENT_CLASS_NAME],
+ async EXPERIMENT_CLASS_NAME => {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ let controlsContainer =
+ shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.ok(
+ controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME)
+ );
+ Assert.ok(pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME));
+ Assert.ok(
+ ContentTaskUtils.isHidden(pipExplainer),
+ "The PiP message should not be visible on the toggle"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js
new file mode 100644
index 0000000000..e998b4f65d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const PIP_EXPERIMENT_MESSAGE = "Hello world message";
+const PIP_EXPERIMENT_TITLE = "Hello world title";
+
+/**
+ * This tests that the original DTD string is shown for the PiP toggle
+ */
+add_task(async function test_experiment_control() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const l10n = new Localization(
+ ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"],
+ true
+ );
+
+ let pipExplainerMessage = l10n.formatValueSync(
+ "videocontrols-picture-in-picture-explainer3"
+ );
+
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [pipExplainerMessage],
+ async function (pipExplainerMessage) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipButton = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.equal(
+ pipButton.textContent.trim(),
+ pipExplainerMessage,
+ "The PiP explainer is default"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the experiment message is shown for the PiP toggle
+ */
+add_task(async function test_experiment_message() {
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ title: PIP_EXPERIMENT_TITLE,
+ message: PIP_EXPERIMENT_MESSAGE,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await doExperimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE],
+ async function (PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+ let pipLabel = shadowRoot.querySelector(".pip-label");
+
+ Assert.equal(
+ pipExplainer.textContent.trim(),
+ PIP_EXPERIMENT_MESSAGE,
+ "The PiP explainer is being overridden by the experiment"
+ );
+
+ Assert.equal(
+ pipLabel.textContent.trim(),
+ PIP_EXPERIMENT_TITLE,
+ "The PiP label is being overridden by the experiment"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js
new file mode 100644
index 0000000000..7c6dcea01b
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const PIP_EXPERIMENT_MESSAGE = "Hello world message";
+const PIP_EXPERIMENT_TITLE = "Hello world title";
+
+/**
+ * This tests that the original DTD string is shown for the PiP toggle
+ */
+add_task(async function test_experiment_control() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ const l10n = new Localization(
+ ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"],
+ true
+ );
+
+ let pipExplainerMessage = l10n.formatValueSync(
+ "videocontrols-picture-in-picture-explainer3"
+ );
+
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [pipExplainerMessage],
+ async function (pipExplainerMessage) {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipButton = shadowRoot.querySelector(".pip-explainer");
+
+ Assert.equal(
+ pipButton.textContent.trim(),
+ pipExplainerMessage,
+ "The PiP explainer is default"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * This tests that the experiment is showing the icon only
+ */
+add_task(async function test_experiment_iconOnly() {
+ let experimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "pictureinpicture",
+ value: {
+ showIconOnly: true,
+ },
+ });
+
+ registerCleanupFunction(async function () {
+ await experimentCleanup();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await SimpleTest.promiseFocus(browser);
+ await ensureVideosReady(browser);
+
+ const PIP_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+ await SpecialPowers.pushPrefEnv({
+ set: [[PIP_PREF, false]],
+ });
+
+ let videoID = "with-controls";
+ await hoverToggle(browser, videoID);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let video = content.document.getElementById("with-controls");
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let pipExpanded = shadowRoot.querySelector(".pip-expanded");
+ let pipIcon = shadowRoot.querySelector("div.pip-icon");
+
+ Assert.ok(
+ ContentTaskUtils.isHidden(pipExpanded),
+ "The PiP explainer hidden by the experiment"
+ );
+
+ Assert.ok(
+ ContentTaskUtils.isVisible(pipIcon),
+ "The PiP icon is visible by the experiment"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js
new file mode 100644
index 0000000000..e021900cad
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that no player controls are triggered by middle or
+ * right click.
+ */
+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 => {
+ 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);
+
+ let playPause = pipWin.document.getElementById("playpause");
+ let audioButton = pipWin.document.getElementById("audio");
+
+ // Middle click the pause button
+ EventUtils.synthesizeMouseAtCenter(playPause, { button: 1 }, pipWin);
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Right click the pause button
+ EventUtils.synthesizeMouseAtCenter(playPause, { button: 2 }, pipWin);
+ ok(!(await isVideoPaused(browser, videoID)), "The video is not paused.");
+
+ // Middle click the mute button
+ EventUtils.synthesizeMouseAtCenter(audioButton, { button: 1 }, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted.");
+
+ // Right click the mute button
+ EventUtils.synthesizeMouseAtCenter(audioButton, { button: 2 }, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted.");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
new file mode 100644
index 0000000000..b6d434cf8c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js
@@ -0,0 +1,46 @@
+/* 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_occluded_window.js b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js
new file mode 100644
index 0000000000..6ee66f2ade
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js
@@ -0,0 +1,258 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set whenever a PiP window is opened
+ * and closed across multiple tabs on the same browser window.
+ */
+add_task(async function forceActiveMultiPiPTabs() {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ let bc = browser.ownerGlobal.browsingContext;
+ info("is window active: " + bc.isActive);
+
+ info("Opening new tab");
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE
+ );
+ let newTabBrowser = newTab.linkedBrowser;
+ await ensureVideosReady(newTabBrowser);
+
+ ok(!bc.forceAppWindowActive, "Forced window active should be false");
+ info("is window active: " + bc.isActive);
+
+ info("Now opening PiP windows");
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let newTabPiPWin = await triggerPictureInPicture(newTabBrowser, videoID);
+ ok(newTabPiPWin, "Got Picture-in-Picture window in new tab");
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after opening a new PiP window in new tab"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosedNewTab = BrowserTestUtils.domWindowClosed(newTabPiPWin);
+ let pipUnloadedNewTab = BrowserTestUtils.waitForEvent(
+ newTabPiPWin,
+ "unload"
+ );
+ let closeButtonNewTab = newTabPiPWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButtonNewTab, {}, newTabPiPWin);
+ info("Waiting for PiP window to close");
+ await pipUnloadedNewTab;
+ await pipClosedNewTab;
+
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after removing new tab's PiP window"
+ );
+
+ info("is window active: " + bc.isActive);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload");
+ let closeButton = pipWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ info("Waiting for PiP window to close");
+ await pipUnloaded;
+ await pipClosed;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should now be false after removing the last PiP window"
+ );
+
+ info("is window active: " + bc.isActive);
+
+ await BrowserTestUtils.removeTab(newTab);
+ }
+ );
+});
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set when a tab with PiP enabled is
+ * moved to another window.
+ */
+add_task(async function forceActiveMovePiPToWindow() {
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ info("Opening first tab");
+
+ await ensureVideosReady(browser);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let bc = browser.ownerGlobal.browsingContext;
+
+ info("is window active: " + bc.isActive);
+
+ ok(!bc.forceAppWindowActive, "Forced window active should be false");
+ info("is window active: " + bc.isActive);
+
+ info("Now opening PiP windows");
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window in first tab.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let swapDocShellsPromise = BrowserTestUtils.waitForEvent(
+ browser,
+ "SwapDocShells"
+ );
+ let tabClosePromise = BrowserTestUtils.waitForEvent(tab, "TabClose");
+ let tabSwapPiPPromise = BrowserTestUtils.waitForEvent(
+ tab,
+ "TabSwapPictureInPicture"
+ );
+ info("Replacing tab with window");
+ let newWindow = gBrowser.replaceTabWithWindow(tab);
+ let newWinLoadedPromise = BrowserTestUtils.waitForEvent(
+ newWindow,
+ "load"
+ );
+
+ info("Waiting for new window to initialize after swap");
+ await Promise.all([
+ tabSwapPiPPromise,
+ swapDocShellsPromise,
+ tabClosePromise,
+ newWinLoadedPromise,
+ ]);
+
+ let newWindowBC = newWindow.browsingContext;
+ tab = newWindow.gBrowser.selectedTab;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should no longer be true after moving the previous tab to a new window"
+ );
+ info("is window active: " + bc.isActive);
+ ok(
+ newWindowBC.forceAppWindowActive,
+ "Force window active should be true for new window since PiP is open"
+ );
+ info("is secondary window active: " + newWindowBC.isActive);
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload");
+ let closeButton = pipWin.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ info("Waiting for PiP window to close");
+ await pipUnloaded;
+ await pipClosed;
+
+ ok(
+ !newWindowBC.forceAppWindowActive,
+ "Force window active should now be false for new window after removing the last PiP window"
+ );
+ info("is secondary window active: " + newWindowBC.isActive);
+
+ await BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+/**
+ * Tests that the forceAppWindowActive flag is correctly set when multiple PiP
+ * windows are created for a single PiP window.
+ */
+add_task(async function forceActiveMultiPiPSamePage() {
+ let videoID1 = "with-controls";
+ let videoID2 = "no-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let bc = browser.ownerGlobal.browsingContext;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Forced window active should be false at the start of the test"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipWin1 = await triggerPictureInPicture(browser, videoID1);
+ ok(pipWin1, "Got Picture-in-Picture window 1.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true since PiP is open"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipWin2 = await triggerPictureInPicture(browser, videoID2);
+ ok(pipWin2, "Got Picture-in-Picture window 2.");
+
+ ok(
+ bc.forceAppWindowActive,
+ "Forced window active should be true after opening another PiP window on the same page"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosed1 = BrowserTestUtils.domWindowClosed(pipWin1);
+ let pipUnloaded1 = BrowserTestUtils.waitForEvent(pipWin1, "unload");
+ let closeButton1 = pipWin1.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton1, {}, pipWin1);
+ info("Waiting for PiP window to close");
+ await pipUnloaded1;
+ await pipClosed1;
+
+ ok(
+ bc.forceAppWindowActive,
+ "Force window active should still be true after removing PiP window 1"
+ );
+ info("is window active: " + bc.isActive);
+
+ let pipClosed2 = BrowserTestUtils.domWindowClosed(pipWin2);
+ let pipUnloaded2 = BrowserTestUtils.waitForEvent(pipWin2, "unload");
+ let closeButton2 = pipWin2.document.getElementById("close");
+ info("Selecting close button");
+ EventUtils.synthesizeMouseAtCenter(closeButton2, {}, pipWin2);
+ info("Waiting for PiP window to close");
+ await pipUnloaded2;
+ await pipClosed2;
+
+ ok(
+ !bc.forceAppWindowActive,
+ "Force window active should now be false after removing PiP window 2"
+ );
+ info("is window active: " + bc.isActive);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
new file mode 100644
index 0000000000..9929da597a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js
@@ -0,0 +1,86 @@
+/* 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 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(browser, videoID)), "The audio is playing.");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ // Try the unmute button
+ let unmutedPromise = waitForVideoEvent("volumechange");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await unmutedPromise;
+ ok(!(await isVideoMuted(browser, videoID)), "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_preserveTabPipIconOverlay.js b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js
new file mode 100644
index 0000000000..55cd003a2c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const EVENTUTILS_URL =
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js";
+var EventUtils = {};
+
+Services.scriptloader.loadSubScript(EVENTUTILS_URL, EventUtils);
+
+async function detachTab(tab) {
+ let newWindowPromise = new Promise((resolve, reject) => {
+ let observe = (win, topic, data) => {
+ Services.obs.removeObserver(observe, "domwindowopened");
+ resolve(win);
+ };
+ Services.obs.addObserver(observe, "domwindowopened");
+ });
+
+ await EventUtils.synthesizePlainDragAndDrop({
+ srcElement: tab,
+
+ // destElement is null because tab detaching happens due
+ // to a drag'n'drop on an invalid drop target.
+ destElement: null,
+
+ // don't move horizontally because that could cause a tab move
+ // animation, and there's code to prevent a tab detaching if
+ // the dragged tab is released while the animation is running.
+ stepX: 0,
+ stepY: 100,
+ });
+
+ return newWindowPromise;
+}
+
+/**
+ * Tests that tabs dragged between windows with PiP open, the pip attribute stays
+ */
+add_task(async function test_dragging_pip_to_other_window() {
+ // initialize
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ win1.gBrowser,
+ TEST_PAGE
+ );
+ let destTab = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser);
+
+ let awaitCloseEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabClose"
+ );
+ let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabSwapPictureInPicture"
+ );
+
+ // Open PiP
+ let videoID = "with-controls";
+ let browser = pipTab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // tear out window
+ let effect = EventUtils.synthesizeDrop(
+ pipTab,
+ destTab,
+ [[{ type: TAB_DROP_TYPE, data: pipTab }]],
+ null,
+ win1,
+ win2
+ );
+ is(effect, "move", "Tab should be moved from win1 to win2.");
+
+ let closeEvent = await awaitCloseEventPromise;
+ let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise;
+
+ is(
+ closeEvent.detail.adoptedBy,
+ swappedPipTabsEvent.detail,
+ "Pip tab adopted by new tab created when original tab closed"
+ );
+
+ // make sure we reassign the pip tab to the new one
+ pipTab = swappedPipTabsEvent.detail;
+
+ // check PiP attribute
+ ok(pipTab.hasAttribute("pictureinpicture"), "Tab should have PiP attribute");
+
+ // end PiP
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ // ensure PiP attribute is gone
+ await TestUtils.waitForCondition(
+ () => !pipTab.hasAttribute("pictureinpicture"),
+ "pictureinpicture attribute was removed"
+ );
+
+ ok(true, "pictureinpicture attribute successfully cleared");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+/**
+ * Tests that tabs torn out into a new window with PiP open, the pip attribute stays
+ */
+add_task(async function test_dragging_pip_into_new_window() {
+ // initialize
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // Create PiP
+ let videoID = "with-controls";
+ let pipTab = gBrowser.getTabForBrowser(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+
+ let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent(
+ pipTab,
+ "TabSwapPictureInPicture"
+ );
+
+ // tear out into new window
+ let newWin = await detachTab(pipTab);
+
+ let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise;
+ pipTab = swappedPipTabsEvent.detail;
+
+ // check PiP attribute
+ ok(
+ pipTab.hasAttribute("pictureinpicture"),
+ "Tab should have PiP attribute"
+ );
+
+ // end PiP
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ // ensure pip attribute is gone
+ await TestUtils.waitForCondition(
+ () => !pipTab.hasAttribute("pictureinpicture"),
+ "pictureinpicture attribute was removed"
+ );
+ ok(true, "pictureinpicture attribute successfully cleared");
+
+ // close windows
+ await BrowserTestUtils.closeWindow(newWin);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_privateWindow.js b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js
new file mode 100644
index 0000000000..26d051b14a
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that a Picture-in-Picture window opened by a Private browsing
+ * window has the "private" feature set on its window (which is important
+ * for some things, eg: taskbar grouping on Windows).
+ */
+add_task(async () => {
+ for (let videoID of ["with-controls", "no-controls"]) {
+ info(`Testing ${videoID} case.`);
+
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let pipTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWin.gBrowser,
+ TEST_PAGE
+ );
+ let browser = pipTab.linkedBrowser;
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ Assert.equal(
+ pipWin.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).chromeFlags &
+ Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW,
+ Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW,
+ "Picture-in-Picture window should be marked as private"
+ );
+
+ await BrowserTestUtils.closeWindow(privateWin);
+ }
+});
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_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
new file mode 100644
index 0000000000..9254ca10cc
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Global values for the left and top edge pixel coordinates. These will be written to
+// during the add_setup function in this test file.
+let gLeftEdge = 0;
+let gTopEdge = 0;
+
+/**
+ * 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 == gLeftEdge) {
+ 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
+ );
+}
+
+add_setup(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ // Reset the saved PiP location to top-left edge of the screen, wherever
+ // that may be. We record the top-left edge of the screen coordinates into
+ // global variables to do later coordinate comparisons after resizes.
+ let clearWin = await triggerPictureInPicture(browser, "with-controls");
+ let initialScreenX = clearWin.mozInnerScreenX;
+ let initialScreenY = clearWin.mozInnerScreenY;
+ let PiPScreen = PictureInPicture.getWorkingScreen(
+ initialScreenX,
+ initialScreenY
+ );
+ [gLeftEdge, gTopEdge] = PictureInPicture.getAvailScreenSize(PiPScreen);
+ clearWin.moveTo(gLeftEdge, gTopEdge);
+
+ await BrowserTestUtils.closeWindow(clearWin);
+ }
+ );
+});
+
+/**
+ * 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(gLeftEdge, gTopEdge);
+
+ 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..1e787dafc7
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js
@@ -0,0 +1,398 @@
+/* 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.
+ */
+async function doTest() {
+ 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;
+ }
+
+ function getAvailScreenSize(screen) {
+ let screenLeft = {},
+ screenTop = {},
+ screenWidth = {},
+ screenHeight = {};
+ screen.GetAvailRectDisplayPix(
+ screenLeft,
+ screenTop,
+ screenWidth,
+ screenHeight
+ );
+
+ // 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 *= scaleFactor;
+ screenTop.value *= scaleFactor;
+
+ 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
+ clearSavedPosition();
+
+ // 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
+ isfuzzy(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP X location"
+ );
+ if (AppConstants.platform == "linux") {
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight - tabHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP Y location"
+ );
+ } else {
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP Y location"
+ );
+ }
+ isfuzzy(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Default PiP height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened with 1/2 default height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ width,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ rightEdge - defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ bottomEdge - defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default Y location"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ defaultPiPWidth,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at default PiP width"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ defaultPiPHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened height with previous width"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location"
+ );
+ isfuzzy(
+ pipWin.innerHeight,
+ height,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened with previous height"
+ );
+ isfuzzy(
+ pipWin.innerWidth,
+ height * (pipWin.innerWidth / pipWin.innerHeight),
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ left,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location but shifted back on screen"
+ );
+ if (AppConstants.platform == "linux") {
+ isfuzzy(
+ pipWin.screenY,
+ top - tabHeight,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last Y location but shifted back on screen"
+ );
+ } else {
+ isfuzzy(
+ pipWin.screenY,
+ top,
+ ACCEPTABLE_DIFFERENCE,
+ "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
+ isfuzzy(
+ pipWin.screenX,
+ defaultX,
+ ACCEPTABLE_DIFFERENCE,
+ "Opened at last X location but shifted back on screen"
+ );
+ isfuzzy(
+ pipWin.screenY,
+ defaultY,
+ ACCEPTABLE_DIFFERENCE,
+ "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;
+ isfuzzy(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ ACCEPTABLE_DIFFERENCE,
+ "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.");
+
+ Assert.less(pipWin.innerWidth, width, "New video width is smaller");
+ isfuzzy(
+ pipWin.innerWidth + pipWin.screenX,
+ rightEdge,
+ ACCEPTABLE_DIFFERENCE,
+ "Video is on right edge after video is changed"
+ );
+ }
+
+ await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true);
+ }
+ );
+}
+
+add_task(async function test_pip_save_last_loc() {
+ await doTest();
+});
+
+add_task(async function test_pip_save_last_loc_with_os_zoom() {
+ await SpecialPowers.pushPrefEnv({ set: [["ui.textScaleFactor", 120]] });
+ await doTest();
+});
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..84866fba3e
--- /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": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+ };
+
+ const TOGGLE_LARGE = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-small": 0.0,
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ ".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_subtitles_settings_panel.js b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js
new file mode 100644
index 0000000000..bbb81cde55
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js
@@ -0,0 +1,273 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that pressing the Escape key will close the subtitles settings panel and
+ * not remove focus if activated via the mouse.
+ */
+add_task(async function test_closePanelESCMouseFocus() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.size",
+ "medium",
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(subtitlesButton, {}, pipWin);
+
+ await panelVisiblePromise;
+
+ let audioButton = pipWin.document.getElementById("audio");
+ audioButton.focus();
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ info("Make sure subtitles settings panel closes after pressing ESC");
+ await panelHiddenPromise;
+
+ Assert.notEqual(
+ pipWin.document.activeElement,
+ subtitlesButton,
+ "Subtitles button does not have focus after closing panel"
+ );
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * Tests that pressing the Escape key will close the subtitles settings panel and
+ * refocus on the subtitles button if activated via the keyboard.
+ */
+add_task(async function test_closePanelESCKeyboardFocus() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ subtitlesButton.focus();
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ await panelVisiblePromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesToggle,
+ "Subtitles switch toggle should have focus after opening panel"
+ );
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape", {}, pipWin);
+
+ info("Make sure subtitles settings panel closes after pressing ESC");
+ await panelHiddenPromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesButton,
+ "Subtitles button has focus after closing panel"
+ );
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
+
+/**
+ * Tests keyboard navigation for the subtitles settings panel and that it closes after selecting
+ * the subtitles button.
+ */
+add_task(async function test_panelKeyboardButtons() {
+ clearSavedPosition();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await ensureVideosReady(browser);
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ // Mute video
+ content.document.getElementById(videoID).muted = true;
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Resize PiP window so that subtitles button is visible
+ let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize");
+ pipWin.resizeTo(640, 360);
+ await resizePromise;
+
+ let subtitlesButton = pipWin.document.getElementById("closed-caption");
+ Assert.ok(subtitlesButton, "Subtitles button found");
+
+ let subtitlesPanel = pipWin.document.getElementById("settings");
+ let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle");
+ let panelVisiblePromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(subtitlesPanel),
+ "Wait for panel to be visible"
+ );
+
+ subtitlesButton.focus();
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ await panelVisiblePromise;
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ subtitlesToggle,
+ "Subtitles switch toggle should have focus after opening panel"
+ );
+
+ let fontMediumRadio = pipWin.document.getElementById("medium");
+ EventUtils.synthesizeKey("KEY_Tab", {}, pipWin);
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ fontMediumRadio,
+ "Medium font size radio button should have focus"
+ );
+
+ let fontSmallRadio = pipWin.document.getElementById("small");
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+
+ Assert.equal(
+ pipWin.document.activeElement,
+ fontSmallRadio,
+ "Small font size radio button should have focus"
+ );
+ Assert.ok(isVideoMuted(browser, videoID), "Video should still be muted");
+ Assert.equal(
+ SpecialPowers.getCharPref(
+ "media.videocontrols.picture-in-picture.display-text-tracks.size"
+ ),
+ "small",
+ "Font size changed to small"
+ );
+
+ subtitlesButton.focus();
+
+ let panelHiddenPromise = BrowserTestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(subtitlesPanel),
+ "Wait for panel to be hidden"
+ );
+
+ EventUtils.synthesizeKey(" ", {}, pipWin);
+
+ info(
+ "Make sure subtitles settings panel closes after pressing the subtitles button"
+ );
+ await panelHiddenPromise;
+
+ Assert.ok(pipWin, "PiP window is still open");
+
+ clearSavedPosition();
+ }
+ );
+});
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_telemetry_enhancements.js b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js
new file mode 100644
index 0000000000..3f027de170
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js
@@ -0,0 +1,230 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const EXPECTED_EVENT_CREATE = [
+ [
+ "pictureinpicture",
+ "create",
+ "player",
+ undefined,
+ { ccEnabled: "false", webVTTSubtitles: "false" },
+ ],
+];
+
+const EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS = [
+ [
+ "pictureinpicture",
+ "create",
+ "player",
+ undefined,
+ { ccEnabled: "true", webVTTSubtitles: "true" },
+ ],
+];
+
+const EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "closeButton",
+ },
+];
+
+const videoID = "with-controls";
+
+const EXPECTED_EVENT_CLOSED_METHOD_UNPIP = [
+ {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "unpip",
+ },
+];
+
+const FULLSCREEN_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ extraKey: { enter: "true" },
+ },
+ {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ extraKey: { enter: "true" },
+ },
+];
+
+add_task(async function testCreateAndCloseButtonTelemetry() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "create",
+ object: "player",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CREATE.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(EXPECTED_EVENT_CREATE, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+
+ filter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "closeButton",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON,
+ filter,
+ { clear: true, process: "parent" }
+ );
+
+ let hist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION"
+ );
+
+ Assert.ok(hist, "Histogram exists");
+ }
+ );
+});
+
+add_task(async function textTextTracksAndUnpipTelemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "create",
+ object: "player",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS,
+ filter,
+ { clear: true, process: "parent" }
+ );
+
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let unpipButton = pipWin.document.getElementById("unpip");
+ EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin);
+ await pipClosed;
+
+ filter = {
+ category: "pictureinpicture",
+ method: "closed_method",
+ object: "unpip",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ EXPECTED_EVENT_CLOSED_METHOD_UNPIP.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ EXPECTED_EVENT_CLOSED_METHOD_UNPIP,
+ filter,
+ { clear: true, process: "parent" }
+ );
+ }
+ );
+});
+
+add_task(async function test_fullscreen_events() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+
+ await ensureVideosReady(browser);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ let fullscreenBtn = pipWin.document.getElementById("fullscreen");
+
+ await promiseFullscreenEntered(pipWin, () => {
+ fullscreenBtn.click();
+ });
+
+ await promiseFullscreenExited(pipWin, () => {
+ fullscreenBtn.click();
+ });
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "fullscreen",
+ object: "player",
+ };
+ await waitForTelemeryEvents(filter, FULLSCREEN_EVENTS.length, "parent");
+
+ TelemetryTestUtils.assertEvents(FULLSCREEN_EVENTS, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ await ensureMessageAndClosePiP(browser, videoID, pipWin, false);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js
new file mode 100644
index 0000000000..b2b1ded13d
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * do not appear on a newly created pip window if the pref
+ * is disabled.
+ */
+add_task(async function test_text_tracks_new_window_pref_disabled() {
+ info("Running test: new window - pref disabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * appear on a newly created pip window if the pref is enabled.
+ */
+add_task(async function test_text_tracks_new_window_pref_enabled() {
+ info("Running test: new window - pref enabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks do not appear on a new pip window
+ * if no track is loaded and the pref is enabled.
+ */
+add_task(async function test_text_tracks_new_window_no_track() {
+ info("Running test: new window - no track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, -1);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js
new file mode 100644
index 0000000000..9f1ebefbf5
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js
@@ -0,0 +1,448 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that text tracks disappear from the pip window
+ * when the pref is disabled.
+ */
+add_task(async function test_text_tracks_existing_window_pref_disabled() {
+ info("Running test: existing window - pref disabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ info("Turning off pref");
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+
+ // Verify that cue is no longer on the pip window
+ info("Checking that cue is no longer on pip window");
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ await ContentTaskUtils.waitForCondition(() => {
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ info("Successfully removed text tracks from pip window");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks shown on the source video
+ * window appear on an existing pip window when the pref is enabled.
+ */
+add_task(async function test_text_tracks_existing_window_pref_enabled() {
+ info("Running test: existing window - pref enabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(
+ !textTracks.textContent,
+ "Text tracks should not be visible on the pip window"
+ );
+ });
+
+ info("Turning on pref");
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ info("Checking that cue is on pip window");
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ info("Successfully displayed text tracks on pip window");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks update to the correct track
+ * when a new track is selected.
+ */
+add_task(async function test_text_tracks_existing_window_new_track() {
+ info("Running test: existing window - new track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("track 1"),
+ "Track 1 should be loaded"
+ );
+ });
+
+ // Change track in the content window
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let tracks = video.textTracks;
+
+ info("Changing to a new track");
+ let track1 = tracks[0];
+ track1.mode = "disabled";
+ let track2 = tracks[1];
+ track2.mode = "showing";
+ });
+
+ // Ensure new track is loaded
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking new text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("track 2"),
+ "Track 2 should be loaded"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks are correctly updated.
+ */
+add_task(async function test_text_tracks_existing_window_cues() {
+ info("Running test: existing window - cues");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+
+ // Verify that first cue appears
+ info("Checking first cue on pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ ok(
+ textTracks.textContent.includes("cue 1"),
+ `Expected text should be displayed on the pip window. Got ${textTracks.textContent}.`
+ );
+ });
+
+ // Play video to move to the next cue
+ await waitForNextCue(browser, videoID);
+
+ // Test remaining cues
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify that empty cue makes text disappear
+ info("Checking empty cue in pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ info(`Current text content is: ${textTracks.textContent}`);
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ await waitForNextCue(browser, videoID);
+
+ // Wait and verify second cue
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ let textTracks = content.document.getElementById("texttracks");
+ info("Checking second cue in pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ // Cue may not appear right away after cuechange event.
+ // Wait until it appears before verifying text content.
+ info(`Current text content is: ${textTracks.textContent}`);
+ return (
+ textTracks.textContent && textTracks.textContent.includes("cue 2")
+ );
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks disappear if no track is selected.
+ */
+add_task(async function test_text_tracks_existing_window_no_track() {
+ info("Running test: existing window - no track");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ });
+
+ // Remove track in the content window
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ let tracks = video.textTracks;
+
+ info("Removing tracks");
+ let track1 = tracks[0];
+ track1.mode = "disabled";
+ let track2 = tracks[1];
+ track2.mode = "disabled";
+ });
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking that text track disappears from pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return !textTracks.textContent;
+ }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`);
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear correctly if there are multiple active cues.
+ */
+add_task(async function test_text_tracks_existing_window_multi_cue() {
+ info("Running test: existing window - multi cue");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, 2);
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify multiple active cues
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+ is(textTracks.children.length, 2, "Text tracks should load 2 cues");
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that the showHiddenTextTracks override correctly shows
+ * text tracks with a mode of "hidden".
+ */
+const prepareHiddenTrackTest = () =>
+ new Promise((resolve, reject) => {
+ BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+
+ async browser => {
+ const videoID = "with-controls";
+ await prepareVideosAndWebVTTTracks(browser, videoID, 0, "hidden");
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ const tracks = video.textTracks;
+ Assert.strictEqual(
+ tracks[0].mode,
+ "hidden",
+ "Track 1 mode is 'hidden'"
+ );
+ });
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+ if (!pipBrowser) {
+ reject();
+ }
+ resolve(pipBrowser);
+ }
+ );
+ });
+
+add_task(async function test_hidden_text_tracks_override() {
+ info("Running test - showHiddenTextTracks");
+
+ info("hidden mode with override");
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {
+ "*://example.com/*": { showHiddenTextTracks: true },
+ });
+ Services.ppmm.sharedData.flush();
+
+ await prepareHiddenTrackTest().then(async pipBrowser => {
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify text track is showing in PiP window.
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ ok(textTracks.textContent.includes("track 1"), "Track 1 should be shown");
+ });
+ });
+
+ info("hidden mode without override");
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY, {});
+ Services.ppmm.sharedData.flush();
+
+ await prepareHiddenTrackTest().then(async pipBrowser => {
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+
+ // Verify text track is [not] showing in PiP window.
+ ok(
+ !textTracks || !textTracks.textContent.length,
+ "Text track should NOT appear in PiP window."
+ );
+ });
+ });
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js
new file mode 100644
index 0000000000..3e62556d45
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verifies the value of a cue's .line property.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} trackIndex The index of the track to be loaded
+ * @param {Integer} cueIndex The index of the cue to be tested on
+ * @param {Integer|String} expectedValue The expected line value of the cue
+ */
+async function verifyLineForCue(
+ browser,
+ videoID,
+ trackIndex,
+ cueIndex,
+ expectedValue
+) {
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, trackIndex, cueIndex, expectedValue }],
+ async args => {
+ info("Checking .line property values");
+ const video = content.document.getElementById(args.videoID);
+ const activeCues = video.textTracks[args.trackIndex].activeCues;
+ const vttCueLine = activeCues[args.cueIndex].line;
+ is(vttCueLine, args.expectedValue, "Cue line should have expected value");
+ }
+ );
+}
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is auto.
+ */
+add_task(async function test_text_tracks_new_window_line_auto() {
+ info("Running test: new window - line auto");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 2;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, "auto");
+ await verifyLineForCue(browser, videoID, trackIndex, 1, "auto");
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+
+ is(cueDivs.length, 2, "There should be 2 active cues");
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 2"),
+ `cue 2 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 1"),
+ `cue 1 should be below. Got: ${cueDivs[1].textContent}`
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is an integer.
+ */
+add_task(async function test_text_tracks_new_window_line_integer() {
+ info("Running test: new window - line integer");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 3;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, 2);
+ await verifyLineForCue(browser, videoID, trackIndex, 1, 3);
+ await verifyLineForCue(browser, videoID, trackIndex, 2, 1);
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+
+ is(cueDivs.length, 3, "There should be 3 active cues");
+
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 3"),
+ `cue 3 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 1"),
+ `cue 1 should be next. Got: ${cueDivs[1].textContent}`
+ );
+ ok(
+ cueDivs[2].textContent.includes("cue 2"),
+ `cue 2 should be below. Got: ${cueDivs[2].textContent}`
+ );
+ });
+ }
+ );
+});
+
+/**
+ * This test ensures that text tracks appear in expected order if
+ * VTTCue.line property is a percentage value.
+ */
+add_task(async function test_text_tracks_new_window_line_percent() {
+ info("Running test: new window - line percent");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let trackIndex = 4;
+ await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+ let pipBrowser = pipWin.document.getElementById("browser");
+
+ await verifyLineForCue(browser, videoID, trackIndex, 0, 90);
+ await verifyLineForCue(browser, videoID, trackIndex, 1, 10);
+ await verifyLineForCue(browser, videoID, trackIndex, 2, 50);
+
+ await SpecialPowers.spawn(pipBrowser, [], async () => {
+ info("Checking text track content in pip window");
+ let textTracks = content.document.getElementById("texttracks");
+ ok(textTracks, "TextTracks container should exist in the pip window");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return textTracks.textContent;
+ }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`);
+
+ let cueDivs = textTracks.children;
+ is(cueDivs.length, 3, "There should be 3 active cues");
+
+ // cue1 in this case refers to the first cue to be defined in the vtt file.
+ // cue2 is therefore the next cue to be defined right after in the vtt file.
+ ok(
+ cueDivs[0].textContent.includes("cue 2"),
+ `cue 2 should be above. Got: ${cueDivs[0].textContent}`
+ );
+ ok(
+ cueDivs[1].textContent.includes("cue 3"),
+ `cue 3 should be next. Got: ${cueDivs[1].textContent}`
+ );
+ ok(
+ cueDivs[2].textContent.includes("cue 1"),
+ `cue 1 should be below. Got: ${cueDivs[2].textContent}`
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
new file mode 100644
index 0000000000..e02fe21f4e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const EXAMPLE_COM = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const EXAMPLE_ORG = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+);
+const EXAMPLE_COM_TEST_PAGE = EXAMPLE_COM + "test-page.html";
+const EXAMPLE_ORG_WITH_IFRAME = EXAMPLE_ORG + "test-page-with-iframe.html";
+
+/**
+ * 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: EXAMPLE_ORG_WITH_IFRAME,
+ gBrowser,
+ },
+ async browser => {
+ // EXAMPLE_ORG_WITH_IFRAME is hosted at a different domain from
+ // EXAMPLE_COM_TEST_PAGE, so loading EXAMPLE_COM_TEST_PAGE within
+ // the iframe will act as our third-party iframe.
+ await SpecialPowers.spawn(
+ browser,
+ [EXAMPLE_COM_TEST_PAGE],
+ async EXAMPLE_COM_TEST_PAGE => {
+ let iframe = content.document.getElementById("iframe");
+ let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load");
+ iframe.src = EXAMPLE_COM_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);
+
+ await SimpleTest.promiseFocus(window);
+
+ // Now try using the command / keyboard shortcut
+ pipWin = await triggerPictureInPicture(iframeBc, videoID, () => {
+ document.getElementById("View:PictureInPicture").doCommand();
+ });
+ ok(pipWin, "Got Picture-in-Picture window using command.");
+
+ 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_toggleButtonOnNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js
new file mode 100644
index 0000000000..6eaa4b5bcd
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that we do not show the Picture-in-Picture toggle on video
+ * elements that have a NaN duration.
+ */
+add_task(async function test_toggleButtonOnNanDuration() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_NAN_VIDEO_DURATION,
+ gBrowser,
+ },
+ async browser => {
+ const VIDEO_ID = "test-video";
+
+ await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) {
+ info(`Waiting for 'canplaythrough' for ${videoID}`);
+ await ContentTaskUtils.waitForEvent(video, "canplaythrough");
+ }
+ });
+
+ await testToggleHelper(browser, "nan-duration", false);
+
+ await testToggleHelper(browser, "test-video", true);
+ }
+ );
+});
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..a845137a69
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js
@@ -0,0 +1,204 @@
+/* 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": DEFAULT_TOGGLE_OPACITY,
+ ".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": DEFAULT_TOGGLE_OPACITY,
+ ".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": DEFAULT_TOGGLE_OPACITY,
+ },
+ hidden: [".pip-expanded"],
+ },
+
+ hoverToggle: {
+ opacities: {
+ ".pip-wrapper": 1.0,
+ },
+ hidden: [".pip-expanded"],
+ },
+ },
+};
+
+const TOGGLE_STYLES_RIGHT_SMALL = {
+ rootID: "pictureInPictureToggle",
+ stages: {
+ hoverVideo: {
+ opacities: {
+ ".pip-wrapper": DEFAULT_TOGGLE_OPACITY,
+ },
+ 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,
+ shouldSeeClickEventAfterToggle: true,
+ },
+ });
+
+ 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,
+ shouldSeeClickEventAfterToggle: true,
+ },
+ });
+
+ 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..36172feeb4
--- /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.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+ );
+ 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..a95bbb0d48
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js
@@ -0,0 +1,127 @@
+/* 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.MIDDLE,
+ 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_togglePositionChange.js b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js
new file mode 100644
index 0000000000..a868bc3d71
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+/**
+ * Tests that Picture-in-Picture "move toggle" context menu item
+ * successfully changes preference.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ await content.document.getElementById(videoID).play();
+ });
+
+ const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+ const TOGGLE_POSITION_RIGHT = "right";
+ const TOGGLE_POSITION_LEFT = "left";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_POSITION_PREF, TOGGLE_POSITION_RIGHT]],
+ });
+
+ let contextMoveToggle = document.getElementById(
+ "context_MovePictureInPictureToggle"
+ );
+ contextMoveToggle.click();
+ let position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+
+ Assert.ok(
+ position === TOGGLE_POSITION_LEFT,
+ "Picture-in-Picture toggle position value should be 'left'."
+ );
+
+ contextMoveToggle.click();
+ position = Services.prefs.getStringPref(
+ TOGGLE_POSITION_PREF,
+ TOGGLE_POSITION_RIGHT
+ );
+
+ Assert.ok(
+ position === TOGGLE_POSITION_RIGHT,
+ "Picture-in-Picture toggle position value should be 'right'."
+ );
+ }
+ );
+});
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_toggle_enabled.js b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js
new file mode 100644
index 0000000000..197bb9357c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "with-controls";
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled";
+const ACCEPTABLE_DIFF = 1;
+
+function checkDifference(actual, expected) {
+ let diff = Math.abs(actual - expected);
+ return diff <= ACCEPTABLE_DIFF;
+}
+
+function isVideoRect(videoRect, rect) {
+ info(
+ "Video rect and toggle rect will be the same if the toggle doesn't show"
+ );
+ info(`Video rect: ${JSON.stringify(videoRect)}`);
+ info(`Toggle rect: ${JSON.stringify(rect)}`);
+ return (
+ checkDifference(videoRect.top, rect.top) &&
+ checkDifference(videoRect.left, rect.left) &&
+ checkDifference(videoRect.width, rect.width) &&
+ checkDifference(videoRect.height, rect.height)
+ );
+}
+
+/**
+ * Tests if the toggle is available depending on prefs
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let videoRect = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ let rect = video.getBoundingClientRect();
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ );
+ // both toggle and pip true
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, true],
+ [PIP_ENABLED_PREF, true],
+ ],
+ });
+
+ let rect = await getToggleClientRect(browser, videoID);
+
+ Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing");
+
+ // only toggle false
+ await SpecialPowers.pushPrefEnv({
+ set: [[TOGGLE_ENABLED_PREF, false]],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+
+ // only pip false
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, true],
+ [PIP_ENABLED_PREF, false],
+ ],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+
+ // both toggle and pip false
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [TOGGLE_ENABLED_PREF, false],
+ [PIP_ENABLED_PREF, false],
+ ],
+ });
+
+ rect = await getToggleClientRect(browser, videoID);
+ Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js
new file mode 100644
index 0000000000..248f816fba
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js
@@ -0,0 +1,76 @@
+/* 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 opening the closed captions menu
+ * and is visible when closing the closed captions menu.
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ["media.videocontrols.picture-in-picture.video-toggle.enabled", true],
+ ],
+ });
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID, -1);
+ await prepareForToggleClick(browser, videoID);
+
+ let args = {
+ videoID,
+ DEFAULT_TOGGLE_STYLES,
+ };
+
+ await SpecialPowers.spawn(browser, [args], async args => {
+ let { videoID, DEFAULT_TOGGLE_STYLES } = args;
+ let video = this.content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+ let closedCaptionButton = shadowRoot.querySelector(
+ "#closedCaptionButton"
+ );
+ let toggle = shadowRoot.querySelector(
+ `#${DEFAULT_TOGGLE_STYLES.rootID}`
+ );
+ let textTrackListContainer = shadowRoot.querySelector(
+ "#textTrackListContainer"
+ );
+
+ Assert.ok(!toggle.hidden, "Toggle should be visible");
+ Assert.ok(
+ textTrackListContainer.hidden,
+ "textTrackListContainer should be hidden"
+ );
+
+ info("Opening text track list from cc button");
+ closedCaptionButton.click();
+
+ Assert.ok(toggle.hidden, "Toggle should be hidden");
+ Assert.ok(
+ !textTrackListContainer.hidden,
+ "textTrackListContainer should be visible"
+ );
+
+ info("Clicking the cc button again to close it");
+ closedCaptionButton.click();
+
+ Assert.ok(!toggle.hidden, "Toggle should be visible again");
+ Assert.ok(
+ textTrackListContainer.hidden,
+ "textTrackListContainer should be hidden again"
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js
new file mode 100644
index 0000000000..0c9ca5eeba
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const videoID = "without-audio";
+const MIN_DURATION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.min-video-secs";
+const ALWAYS_SHOW_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.always-show";
+const ACCEPTABLE_DIFF = 1;
+
+function checkDifference(actual, expected) {
+ let diff = Math.abs(actual - expected);
+ return diff <= ACCEPTABLE_DIFF;
+}
+
+function isVideoRect(videoRect, rect) {
+ info(
+ "Video rect and toggle rect will be the same if the toggle doesn't show"
+ );
+ info(`Video rect: ${JSON.stringify(videoRect)}`);
+ info(`Toggle rect: ${JSON.stringify(rect)}`);
+ return (
+ checkDifference(videoRect.top, rect.top) &&
+ checkDifference(videoRect.left, rect.left) &&
+ checkDifference(videoRect.width, rect.width) &&
+ checkDifference(videoRect.height, rect.height)
+ );
+}
+
+/**
+ * Tests if the toggle is available for a video without an audio track
+ */
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE_WITHOUT_AUDIO,
+ },
+ async browser => {
+ let videoRect = await SpecialPowers.spawn(
+ browser,
+ [videoID],
+ async videoID => {
+ let video = content.document.getElementById(videoID);
+ Assert.ok(!video.mozHasAudio, "Video does not have an audio track");
+ let rect = video.getBoundingClientRect();
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ALWAYS_SHOW_PREF, false], // don't always show, we're testing the display logic
+ [MIN_DURATION_PREF, 3], // sample video is only 4s
+ ],
+ });
+
+ let rect = await getToggleClientRect(browser, videoID);
+
+ Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing");
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js
new file mode 100644
index 0000000000..e50c430d0c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js
@@ -0,0 +1,242 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that Picture-in-Picture intializes without changes to video playback
+ * when opened via the toggle using a touch event. Also ensures that elements
+ * in the content window can still be interacted with afterwards.
+ */
+add_task(async () => {
+ let videoID = "with-controls";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.videocontrols.picture-in-picture.video-toggle.position", "right"],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+ let toggleStyles = DEFAULT_TOGGLE_STYLES;
+ let stage = "hoverVideo";
+ let toggleStylesForStage = toggleStyles.stages[stage];
+
+ // Remove other page elements before reading PiP toggle's client rect.
+ // Otherwise, we will provide the wrong coordinates when simulating the touch event.
+ await SpecialPowers.spawn(browser, [], async args => {
+ info(
+ "Removing other elements first to make the PiP toggle more visible"
+ );
+ this.content.document.getElementById("no-controls").remove();
+ let otherEls = this.content.document.querySelectorAll("h1");
+ for (let el of otherEls) {
+ el.remove();
+ }
+ });
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+ await prepareForToggleClick(browser, videoID);
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, toggleClientRect, toggleStylesForStage }],
+ async args => {
+ // waitForToggleOpacity is based on toggleOpacityReachesThreshold.
+ // Waits for toggle to reach target opacity.
+ async function waitForToggleOpacity(
+ shadowRoot,
+ toggleStylesForStage
+ ) {
+ for (let hiddenElement of toggleStylesForStage.hidden) {
+ let el = shadowRoot.querySelector(hiddenElement);
+ ok(
+ ContentTaskUtils.isHidden(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.");
+ }
+ }
+ let { videoID, toggleClientRect, toggleStylesForStage } = args;
+ let video = this.content.document.getElementById(videoID);
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ info("Creating a new button in the content window");
+ let button = this.content.document.createElement("button");
+ let buttonSelected = false;
+ button.ontouchstart = () => {
+ info("Button selected via touch event");
+ buttonSelected = true;
+ return true;
+ };
+ button.id = "testbutton";
+ button.style.backgroundColor = "red";
+ // Move button to the top for better visibility.
+ button.style.position = "absolute";
+ button.style.top = "0";
+ button.textContent = "test button";
+ this.content.document.body.appendChild(button);
+
+ await video.play();
+
+ info("Hover over the video to show the Picture-in-Picture toggle");
+ await EventUtils.synthesizeMouseAtCenter(
+ video,
+ { type: "mousemove" },
+ this.content.window
+ );
+ await EventUtils.synthesizeMouseAtCenter(
+ video,
+ { type: "mouseover" },
+ this.content.window
+ );
+
+ let toggleCenterX =
+ toggleClientRect.left + toggleClientRect.width / 2;
+ let toggleCenterY =
+ toggleClientRect.top + toggleClientRect.height / 2;
+
+ // We want to wait for the toggle to reach opacity so that we can select it.
+ info("Waiting for toggle to become fully visible");
+ await waitForToggleOpacity(shadowRoot, toggleStylesForStage);
+
+ info("Simulating touch event on PiP toggle");
+ let utils = EventUtils._getDOMWindowUtils(this.content.window);
+ let id = utils.DEFAULT_TOUCH_POINTER_ID;
+ let rx = 1;
+ let ry = 1;
+ let angle = 0;
+ let force = 1;
+ let tiltX = 0;
+ let tiltY = 0;
+ let twist = 0;
+
+ let defaultPrevented = utils.sendTouchEvent(
+ "touchstart",
+ [id],
+ [toggleCenterX],
+ [toggleCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+ utils.sendTouchEvent(
+ "touchend",
+ [id],
+ [toggleCenterX],
+ [toggleCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+
+ ok(
+ defaultPrevented,
+ "Touchstart event's default actions should be prevented"
+ );
+ ok(!video.paused, "Video should still be playing");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+
+ let testButton = this.content.document.getElementById("testbutton");
+ let buttonRect = testButton.getBoundingClientRect();
+ let buttonCenterX = buttonRect.left + buttonRect.width / 2;
+ let buttonCenterY = buttonRect.top + buttonRect.height / 2;
+
+ info("Simulating touch event on new button");
+ defaultPrevented = utils.sendTouchEvent(
+ "touchstart",
+ [id],
+ [buttonCenterX],
+ [buttonCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+ utils.sendTouchEvent(
+ "touchend",
+ [id],
+ [buttonCenterX],
+ [buttonCenterY],
+ [rx],
+ [ry],
+ [angle],
+ [force],
+ [tiltX],
+ [tiltY],
+ [twist],
+ false /* modifiers */
+ );
+
+ ok(
+ buttonSelected,
+ "Button in content window was selected via touchstart"
+ );
+ ok(
+ !defaultPrevented,
+ "Touchstart event's default actions should no longer be prevented"
+ );
+ }
+ );
+
+ try {
+ info("Picture-in-Picture window should open");
+ await BrowserTestUtils.waitForCondition(
+ () => Services.wm.getEnumerator(WINDOW_TYPE).hasMoreElements(),
+ "Found a Picture-in-Picture"
+ );
+ for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
+ if (!win.closed) {
+ ok(true, "Found a Picture-in-Picture window as expected");
+ win.close();
+ }
+ }
+ } catch {
+ ok(false, "No Picture-in-Picture window found, which is unexpected");
+ }
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js
new file mode 100644
index 0000000000..cf808c5177
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js
@@ -0,0 +1,456 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const PIP_URLBAR_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ },
+];
+
+const PIP_DISABLED_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ extra: { disableDialog: "true" },
+ },
+ {
+ category: "pictureinpicture",
+ method: "disrespect_disable",
+ object: "urlBar",
+ },
+];
+
+const FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS = [
+ {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ extra: { firstTimeToggle: "true", callout: "true" },
+ },
+];
+
+add_task(async function test_urlbar_toggle_multiple_contexts() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_MULTIPLE_CONTEXTS,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await ensureVideosReady(browser);
+ await ensureVideosReady(browser.browsingContext.children[0]);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 2,
+ "Waiting for videos to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 2, "Total PiP count is 2");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there is more than 1 video"
+ );
+
+ // Remove one video from page so urlbar toggle will show
+ await SpecialPowers.spawn(browser, [], async () => {
+ let video = content.document.getElementById("with-controls");
+ video.remove();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isVisible(pipUrlbarToggle)
+ );
+
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible"
+ );
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(
+ browser.browsingContext.children[0],
+ "video"
+ );
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(filter, PIP_URLBAR_EVENTS.length, "content");
+
+ TelemetryTestUtils.assertEvents(PIP_URLBAR_EVENTS, filter, {
+ clear: true,
+ process: "content",
+ });
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let iframe = content.document.getElementById("iframe");
+ iframe.remove();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isHidden(pipUrlbarToggle)
+ );
+
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there are no videos on the page"
+ );
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 0, "Total PiP count is 0");
+ }
+ );
+});
+
+add_task(async function test_urlbar_toggle_switch_tabs() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_TRANSPARENT_NESTED_IFRAMES,
+ gBrowser,
+ },
+ async browser => {
+ await ensureVideosReady(browser);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 1,
+ "Waiting for video to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible because there is 1 video"
+ );
+
+ let pipActivePromise = BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "video");
+
+ await pipActivePromise;
+
+ ok(
+ pipUrlbarToggle.hasAttribute("pipactive"),
+ "We are PiP'd in this tab so the icon is active"
+ );
+
+ let newTab = BrowserTestUtils.addTab(
+ gBrowser,
+ TEST_PAGE_TRANSPARENT_NESTED_IFRAMES
+ );
+ await BrowserTestUtils.browserLoaded(newTab.linkedBrowser);
+
+ await BrowserTestUtils.switchTab(gBrowser, newTab);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => !pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ ok(
+ !pipUrlbarToggle.hasAttribute("pipactive"),
+ "After switching tabs the pip icon is not active"
+ );
+
+ BrowserTestUtils.removeTab(newTab);
+
+ await ensureMessageAndClosePiP(
+ browser,
+ "video-transparent-background",
+ win,
+ false
+ );
+ }
+ );
+});
+
+add_task(async function test_pipDisabled() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_PIP_DISABLED,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.respect-disablePictureInPicture",
+ true,
+ ],
+ ],
+ });
+
+ const VIDEO_ID = "with-controls";
+ await ensureVideosReady(browser);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 1,
+ "Waiting for video to register"
+ );
+
+ let { totalPipCount, totalPipDisabled } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 1, "Total PiP count is 1");
+ is(totalPipDisabled, 1, "PiP is disabled on 1 video");
+
+ // Confirm that the toggle is hidden because we are respecting disablePictureInPicture
+ await testToggleHelper(browser, VIDEO_ID, false);
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible because PiP is disabled"
+ );
+
+ pipUrlbarToggle.click();
+
+ let panel = browser.ownerDocument.querySelector("#PictureInPicturePanel");
+ await BrowserTestUtils.waitForCondition(async () => {
+ if (!panel) {
+ panel = browser.ownerDocument.querySelector("#PictureInPicturePanel");
+ }
+ return BrowserTestUtils.isVisible(panel);
+ });
+
+ let respectPipDisableSwitch = panel.querySelector(
+ "#respect-pipDisabled-switch"
+ );
+ ok(
+ !respectPipDisableSwitch.pressed,
+ "Respect PiP disabled is not pressed"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(respectPipDisableSwitch.buttonEl, {});
+ await BrowserTestUtils.waitForEvent(respectPipDisableSwitch, "toggle");
+ ok(respectPipDisableSwitch.pressed, "Respect PiP disabled is pressed");
+
+ pipUrlbarToggle.click();
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return BrowserTestUtils.isHidden(panel);
+ });
+
+ let filter = {
+ category: "pictureinpicture",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(filter, PIP_DISABLED_EVENTS.length, "parent");
+ TelemetryTestUtils.assertEvents(PIP_DISABLED_EVENTS, filter, {
+ clear: true,
+ process: "parent",
+ });
+
+ // Confirm that the toggle is now visible because we no longer respect disablePictureInPicture
+ await testToggleHelper(browser, VIDEO_ID, true);
+
+ let pipActivePromise = BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+
+ await assertVideoIsBeingCloned(browser, "video");
+
+ await pipActivePromise;
+
+ ok(
+ pipUrlbarToggle.hasAttribute("pipactive"),
+ "We are PiP'd in this tab so the icon is active"
+ );
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["pipactive"] },
+ () => !pipUrlbarToggle.hasAttribute("pipactive")
+ );
+
+ ok(
+ !pipUrlbarToggle.hasAttribute("pipactive"),
+ "We closed the PiP window so the urlbar button is no longer active"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ }
+ );
+});
+
+add_task(async function test_urlbar_toggle_telemetry() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_MULTIPLE_CONTEXTS,
+ gBrowser,
+ },
+ async browser => {
+ Services.telemetry.clearEvents();
+ await ensureVideosReady(browser);
+ await ensureVideosReady(browser.browsingContext.children[0]);
+
+ await TestUtils.waitForCondition(
+ () =>
+ PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount ===
+ 2,
+ "Waiting for videos to register"
+ );
+
+ let { totalPipCount } =
+ PictureInPicture.getEligiblePipVideoCount(browser);
+ is(totalPipCount, 2, "Total PiP count is 2");
+
+ let pipUrlbarToggle = document.getElementById(
+ "picture-in-picture-button"
+ );
+ ok(
+ BrowserTestUtils.isHidden(pipUrlbarToggle),
+ "PiP urlbar toggle is hidden because there is more than 1 video"
+ );
+
+ // Remove one video from page so urlbar toggle will show
+ await SpecialPowers.spawn(browser, [], async () => {
+ let video = content.document.getElementById("with-controls");
+ video.remove();
+ });
+ await BrowserTestUtils.waitForMutationCondition(
+ pipUrlbarToggle,
+ { attributeFilter: ["hidden"] },
+ () => BrowserTestUtils.isVisible(pipUrlbarToggle)
+ );
+ ok(
+ BrowserTestUtils.isVisible(pipUrlbarToggle),
+ "PiP urlbar toggle is visible"
+ );
+
+ // open for first time after seeing feature callout message
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.video-toggle.has-used",
+ false,
+ ],
+ ],
+ });
+ let fakeMessage = {
+ template: "feature_callout",
+ id: "FAKE_PIP_FEATURE_CALLOUT",
+ content: {
+ screens: [{ anchors: [{ selector: "#picture-in-picture-button" }] }],
+ },
+ };
+ await ASRouter.waitForInitialized;
+ let originalASRouterState;
+ await ASRouter.setState(state => {
+ originalASRouterState = state;
+ return {
+ messages: [...state.messages, fakeMessage],
+ messageImpressions: {
+ ...state.messageImpressions,
+ [fakeMessage.id]: [Date.now()],
+ },
+ };
+ });
+
+ ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser));
+ is(totalPipCount, 1, "Total PiP count is 1");
+
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ pipUrlbarToggle.click();
+ let win = await domWindowOpened;
+ ok(win, "A Picture-in-Picture window opened.");
+ await assertVideoIsBeingCloned(
+ browser.browsingContext.children[0],
+ "video"
+ );
+
+ let filter = {
+ category: "pictureinpicture",
+ method: "opened_method",
+ object: "urlBar",
+ };
+ await waitForTelemeryEvents(
+ filter,
+ FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS.length,
+ "content"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS,
+ filter,
+ { clear: true, process: "content" }
+ );
+
+ let domWindowClosed = BrowserTestUtils.domWindowClosed(win);
+ pipUrlbarToggle.click();
+ await domWindowClosed;
+
+ await ASRouter.setState(originalASRouterState);
+ }
+ );
+});
diff --git a/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js
new file mode 100644
index 0000000000..bc96a9ea58
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the subtitles button hides after switching to a video that does not have subtitles
+ */
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ true,
+ ],
+ ],
+ });
+
+ let videoID = "with-controls";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ await prepareVideosAndWebVTTTracks(browser, videoID);
+
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Need to make sure that the PiP window is at least the minimum height
+ let multiplier = 1;
+ while (true) {
+ if (multiplier * pipWin.innerHeight > 325) {
+ break;
+ }
+ multiplier += 0.5;
+ }
+
+ pipWin.moveTo(50, 50);
+ pipWin.resizeTo(
+ pipWin.innerWidth * multiplier,
+ pipWin.innerHeight * multiplier
+ );
+
+ let subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return !subtitlesButton.disabled;
+ }, "Waiting for subtitles button to be enabled");
+ ok(!subtitlesButton.disabled, "The subtitles button is enabled");
+
+ let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Waiting for emptied event to be called");
+ await ContentTaskUtils.waitForEvent(video, "emptied");
+ });
+
+ await SpecialPowers.spawn(browser, [{ videoID }], async args => {
+ let video = content.document.getElementById(args.videoID);
+ video.setAttribute("src", video.src);
+ let len = video.textTracks.length;
+ for (let i = 0; i < len; i++) {
+ video.removeChild(video.children[0]);
+ }
+ video.load();
+ });
+
+ await emptied;
+
+ await TestUtils.waitForCondition(() => {
+ return subtitlesButton.disabled;
+ }, "Waiting for subtitles button to be disabled after it was enabled");
+ ok(subtitlesButton.disabled, "The subtitles button is disabled");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
+
+/**
+ * Tests the the subtitles button shows after switching from a video with no subtitles to a video with subtitles
+ */
+add_task(async () => {
+ const videoID = "with-controls";
+ const videoID2 = "with-controls-no-tracks";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: TEST_PAGE_WITH_WEBVTT,
+ gBrowser,
+ },
+ async browser => {
+ let pipWin = await triggerPictureInPicture(browser, videoID2);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Need to make sure that the PiP window is at least the minimum height
+ let multiplier = 1;
+ while (true) {
+ if (multiplier * pipWin.innerHeight > 325) {
+ break;
+ }
+ multiplier += 0.5;
+ }
+
+ pipWin.moveTo(50, 50);
+ pipWin.resizeTo(
+ pipWin.innerWidth * multiplier,
+ pipWin.innerHeight * multiplier
+ );
+
+ let subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return subtitlesButton.disabled;
+ }, "Making sure the subtitles button is disabled initially");
+ ok(subtitlesButton.disabled, "The subtitles button is disabled");
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, videoID2 }],
+ async args => {
+ let video2 = content.document.getElementById(args.videoID2);
+
+ let track = video2.addTextTrack("captions", "English", "en");
+ track.mode = "showing";
+ track.addCue(
+ new content.window.VTTCue(0, 12, "[Test] This is the first cue")
+ );
+ track.addCue(
+ new content.window.VTTCue(18.7, 21.5, "This is the second cue")
+ );
+
+ video2.setAttribute("src", video2.src);
+ video2.load();
+
+ is(
+ video2.textTracks.length,
+ 1,
+ "Number of tracks loaded should be 1"
+ );
+ video2.play();
+ video2.pause();
+ }
+ );
+
+ subtitlesButton = pipWin.document.querySelector("#closed-caption");
+ await TestUtils.waitForCondition(() => {
+ return !subtitlesButton.disabled;
+ }, "Waiting for the subtitles button to be enabled after switching to a video with subtitles.");
+ ok(!subtitlesButton.disabled, "The subtitles button is enabled");
+
+ await BrowserTestUtils.closeWindow(pipWin);
+ }
+ );
+});
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..7e792b2e9e
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/head.js
@@ -0,0 +1,1153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TOGGLE_POLICIES } = ChromeUtils.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+);
+
+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 TEST_PAGE_WITHOUT_AUDIO = TEST_ROOT + "test-page-without-audio.html";
+const TEST_PAGE_WITH_NAN_VIDEO_DURATION =
+ TEST_ROOT + "test-page-with-nan-video-duration.html";
+const TEST_PAGE_WITH_WEBVTT = TEST_ROOT + "test-page-with-webvtt.html";
+const TEST_PAGE_MULTIPLE_CONTEXTS =
+ TEST_ROOT + "test-page-multiple-contexts.html";
+const TEST_PAGE_TRANSPARENT_NESTED_IFRAMES =
+ TEST_ROOT + "test-transparent-nested-iframes.html";
+const TEST_PAGE_PIP_DISABLED = TEST_ROOT + "test-page-pipDisabled.html";
+const WINDOW_TYPE = "Toolkit:PictureInPicture";
+const TOGGLE_POSITION_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.position";
+/* As of Bug 1811312, 80% toggle opacity is for the PiP toggle experiment control. */
+const DEFAULT_TOGGLE_OPACITY = 0.8;
+const HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides";
+// Used for clearing the size and location of the PiP window
+const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
+const ACCEPTABLE_DIFFERENCE = 2;
+
+/**
+ * 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": DEFAULT_TOGGLE_OPACITY,
+ },
+ 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.
+ *
+ * If triggerFn is not specified, then open using the
+ * MozTogglePictureInPicture event.
+ *
+ * @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 {boolean} triggerFn Use the given function to open the pip window,
+ * which runs in the parent process.
+ *
+ * @return Promise
+ * @resolves With the Picture-in-Picture window when ready.
+ */
+async function triggerPictureInPicture(browser, videoID, triggerFn) {
+ let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+
+ let videoReady = null;
+ if (triggerFn) {
+ await SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ video.focus();
+ });
+
+ triggerFn();
+
+ videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => {
+ let video = content.document.getElementById(videoID);
+ await ContentTaskUtils.waitForCondition(() => {
+ return video.isCloningElementVisually;
+ }, "Video is being cloned visually.");
+ });
+ } else {
+ 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 or browsing contect 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, selector) {
+ return SpecialPowers.spawn(browser, [selector], async slctr => {
+ let video = content.document.querySelector(slctr);
+ 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) or the browsing context
+ *
+ * @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) {
+ video.currentTime = 0;
+ 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.isHidden(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.sys.mjs.
+ * 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.importESModule(
+ "resource://gre/modules/PictureInPictureControls.sys.mjs"
+ );
+ 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."
+ );
+}
+
+/**
+ * Tests that a click event is 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.
+ * @return Promise
+ * @resolves When the check has completed.
+ */
+async function assertSawClickEventOnly(browser) {
+ let mouseEvents = await SpecialPowers.spawn(browser, [], async () => {
+ return this.content.wrappedJSObject.getRecordedEvents();
+ });
+ Assert.deepEqual(
+ mouseEvents,
+ ["click"],
+ "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.importESModule(
+ "resource://gre/actors/PictureInPictureChild.sys.mjs"
+ );
+ 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.importESModule(
+ "resource://gre/modules/Geometry.sys.mjs"
+ );
+
+ 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,
+ };
+ });
+}
+
+/**
+ * This function will hover over the middle of the video and then
+ * hover over the toggle.
+ * @param browser The current browser
+ * @param videoID The video element id
+ */
+async function hoverToggle(browser, videoID) {
+ 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, null);
+
+ let toggleClientRect = await getToggleClientRect(browser, videoID);
+
+ 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
+ );
+}
+
+/**
+ * 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.sys.mjs.
+ *
+ * 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, shouldSeeClickEventAfterToggle },
+ ] of Object.entries(expectations)) {
+ await SimpleTest.promiseFocus(browser);
+ info(`Testing video with id: ${videoID}`);
+
+ await testToggleHelper(
+ browser,
+ videoID,
+ canToggle,
+ policy,
+ toggleStyles,
+ shouldSeeClickEventAfterToggle
+ );
+ }
+ }
+ );
+}
+
+/**
+ * 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.sys.mjs.
+ * @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,
+ shouldSeeClickEventAfterToggle
+) {
+ 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);
+
+ // We do get a "Click" sometimes, it depends on many
+ // factors such as whether the video has control and
+ // the style of the toggle.
+ if (shouldSeeClickEventAfterToggle) {
+ await assertSawClickEventOnly(browser);
+ } else {
+ // 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");
+ });
+
+ 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
+ dump(`BJW promiseFullscreenEntered: waiting for 2 second timeout.\n`);
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+}
+
+/**
+ * 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;
+ });
+}
+
+/**
+ * Helper function that returns True if the specified video is muted
+ * and False if the specified video is not muted.
+ *
+ * @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 isVideoMuted(browser, videoID) {
+ return SpecialPowers.spawn(browser, [videoID], async videoID => {
+ return content.document.getElementById(videoID).muted;
+ });
+}
+
+/**
+ * Initializes videos and text tracks for the current test case.
+ * First track is the default track to be loaded onto the video.
+ * Once initialization is done, play then pause the requested video.
+ * so that text tracks are loaded.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} defaultTrackIndex The index of the track to be loaded, or none if -1
+ * @param {String} trackMode the mode that the video's textTracks should be set to
+ */
+async function prepareVideosAndWebVTTTracks(
+ browser,
+ videoID,
+ defaultTrackIndex = 0,
+ trackMode = "showing"
+) {
+ info("Preparing video and initial text tracks");
+ await ensureVideosReady(browser);
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, defaultTrackIndex, trackMode }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ let tracks = video.textTracks;
+
+ is(tracks.length, 5, "Number of tracks loaded should be 5");
+
+ // Enable track for originating video
+ if (args.defaultTrackIndex >= 0) {
+ info(`Loading track ${args.defaultTrackIndex + 1}`);
+ let track = tracks[args.defaultTrackIndex];
+ tracks.mode = args.trackMode;
+ track.mode = args.trackMode;
+ }
+
+ // Briefly play the video to load text tracks onto the pip window.
+ info("Playing video to load text tracks");
+ video.play();
+ info("Pausing video");
+ video.pause();
+ ok(video.paused, "Video should be paused before proceeding with test");
+ }
+ );
+}
+
+/**
+ * Plays originating video until the next cue is loaded.
+ * Once the next cue is loaded, pause the video.
+ * @param {Element} browser The <xul:browser> hosting the <video>
+ * @param {String} videoID The ID of the video being checked
+ * @param {Integer} textTrackIndex The index of the track to be loaded, or none if -1
+ */
+async function waitForNextCue(browser, videoID, textTrackIndex = 0) {
+ if (textTrackIndex < 0) {
+ ok(false, "Cannot wait for next cue with invalid track index");
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ videoID, textTrackIndex }],
+ async args => {
+ let video = content.document.getElementById(args.videoID);
+ info("Playing video to activate next cue");
+ video.play();
+ ok(!video.paused, "Video is playing");
+
+ info("Waiting until cuechange is called");
+ await ContentTaskUtils.waitForEvent(
+ video.textTracks[args.textTrackIndex],
+ "cuechange"
+ );
+
+ info("Pausing video to read text track");
+ video.pause();
+ ok(video.paused, "Video is paused");
+ }
+ );
+}
+
+/**
+ * The PiP window saves the positon when closed and sometimes we don't want
+ * this information to persist to other tests. This function will clear the
+ * position so the PiP window will open in the default position.
+ */
+function clearSavedPosition() {
+ 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 overrideSavedPosition(left, top, width, height) {
+ let xulStore = Services.xulStore;
+ 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);
+}
+
+/**
+ * Function used to filter events when waiting for the correct number
+ * telemetry events.
+ * @param {String} expected The expected string or undefined
+ * @param {String} actual The actual string
+ * @returns true if the expected is undefined or if expected matches actual
+ */
+function matches(expected, actual) {
+ if (expected === undefined) {
+ return true;
+ }
+ return expected === actual;
+}
+
+/**
+ * Function that waits for the expected number of events aftering filtering.
+ * @param {Object} filter An object containing optional filters
+ * {
+ * category: (optional) The category of the event. Ex. "pictureinpicture"
+ * method: (optional) The method of the event. Ex. "create"
+ * object: (optional) The object of the event. Ex. "player"
+ * }
+ * @param {Number} length The number of events to wait for
+ * @param {String} process Should be "content" or "parent" depending on the event
+ */
+async function waitForTelemeryEvents(filter, length, process) {
+ let {
+ category: filterCategory,
+ method: filterMethod,
+ object: filterObject,
+ } = filter;
+
+ let events = [];
+ await TestUtils.waitForCondition(
+ () => {
+ events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ )[process];
+ if (!events) {
+ return false;
+ }
+
+ let filtered = events
+ .map(([, /* timestamp */ category, method, object, value, extra]) => {
+ // We don't care about the `timestamp` value.
+ // Tests that examine that value should use `snapshotEvents` directly.
+ return [category, method, object, value, extra];
+ })
+ .filter(([category, method, object]) => {
+ return (
+ matches(filterCategory, category) &&
+ matches(filterMethod, method) &&
+ matches(filterObject, object)
+ );
+ });
+ info(JSON.stringify(filtered, null, 2));
+ return filtered && filtered.length >= length;
+ },
+ `Waiting for ${length} pictureinpicture telemetry event(s) with filter ${JSON.stringify(
+ filter,
+ null,
+ 2
+ )}`,
+ 200,
+ 100
+ );
+}
diff --git a/toolkit/components/pictureinpicture/tests/no-audio-track.webm b/toolkit/components/pictureinpicture/tests/no-audio-track.webm
new file mode 100644
index 0000000000..72b0297233
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/no-audio-track.webm
Binary files differ
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-multiple-contexts.html b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html
new file mode 100644
index 0000000000..420370aff2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Picture-in-Picture tests</title>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <h1>Video in page</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <h1>Video in frame</h1>
+ <iframe id="iframe" width="400" height="225" src="test-video.mp4"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html
new file mode 100644
index 0000000000..142d2d74d2
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html
@@ -0,0 +1,18 @@
+<!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 PiPDisabled</h1>
+ <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225" disablePictureInPicture="true"></video>
+</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-nan-video-duration.html b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html
new file mode 100644
index 0000000000..b16c3682a0
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Bug 1679174</title>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+ </head>
+ <body>
+ <video id="nan-duration"></video>
+ <video controls id="test-video">
+ <source src="test-video.mp4" type="video/mp4">
+ </video>
+ </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-with-webvtt.html b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html
new file mode 100644
index 0000000000..e05ad8dd2c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html
@@ -0,0 +1,66 @@
+<!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-long.mp4" controls width="400" height="225">
+ <track
+ id="track1"
+ kind="captions"
+ label="[test] en"
+ srclang="en"
+ src="test-webvtt-1.vtt"
+ />
+ <track
+ id="track2"
+ kind="subtitles"
+ label="[test] fr"
+ srclang="fr"
+ src="test-webvtt-2.vtt"
+ />
+ <track
+ id="track3"
+ kind="subtitles"
+ label="[test] eo"
+ srclang="eo"
+ src="test-webvtt-3.vtt"
+ />
+ <track
+ id="track4"
+ kind="subtitles"
+ label="[test] zh"
+ srclang="zh"
+ src="test-webvtt-4.vtt"
+ />
+ <track
+ id="track5"
+ kind="subtitles"
+ label="[test] es"
+ srclang="es"
+ src="test-webvtt-5.vtt"
+ />
+ </video>
+
+ <video id="with-controls-no-tracks" src="test-video-long.mp4" controls width="400" height="225"></video>
+
+ <script>
+ function fireEvents() {
+ for (let videoID of ["with-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-page-without-audio.html b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html
new file mode 100644
index 0000000000..862042cd59
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html
@@ -0,0 +1,18 @@
+<!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 without audio track</h1>
+ <video id="without-audio" src="no-audio-track.webm" controls width="400" height="225"></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
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt
new file mode 100644
index 0000000000..fd16ef6d32
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt
@@ -0,0 +1,10 @@
+WEBVTT
+
+1
+00:00:00.000 --> 00:00:01.000
+track 1 - cue 1
+
+2
+00:00:02.000 --> 00:00:05.000
+- <b>track 1 - cue 2 bold</b>
+- <i>track 1 - cue 2 italicized<i>
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt
new file mode 100644
index 0000000000..21fda2b75c
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt
@@ -0,0 +1,10 @@
+WEBVTT
+
+1
+00:00:00.000 --> 00:00:01.000
+track 2 - cue 1
+
+2
+00:00:02.000 --> 00:00:05.000
+- <b>track 2 - cue 2 bold</b>
+- <i>track 2 - cue 2 italicized<i>
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt
new file mode 100644
index 0000000000..0207d9e65f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt
@@ -0,0 +1,11 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as "auto"
+
+1
+00:00:00.000 --> 00:00:01.000
+track 3 - cue 1
+
+2
+00:00:00.000 --> 00:00:01.000
+track 3 - cue 2
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt
new file mode 100644
index 0000000000..9e4a540f7f
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt
@@ -0,0 +1,15 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as an integer
+
+1
+00:00:00.000 --> 00:00:01.000 line:2
+track 4 - cue 1 - integer line
+
+2
+00:00:00.000 --> 00:00:01.000 line:3
+track 4 - cue 2 - integer line
+
+3
+00:00:00.000 --> 00:00:01.000 line:1
+track 4 - cue 3 - integer line
diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt
new file mode 100644
index 0000000000..3a25d83529
--- /dev/null
+++ b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt
@@ -0,0 +1,12 @@
+WEBVTT
+
+Test file with multiple active cues and VTTCue.line as a percentage value
+
+00:00:00.000 --> 00:00:01.000 line:90%
+track 5 - cue 1 - percent line
+
+00:00:00.000 --> 00:00:01.000 line:10%
+track 5 - cue 2 - percent line
+
+00:00:00.000 --> 00:00:01.000 line:50%
+track 5 - cue 3 - percent line