summaryrefslogtreecommitdiffstats
path: root/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/pictureinpicture/PictureInPicture.sys.mjs')
-rw-r--r--toolkit/components/pictureinpicture/PictureInPicture.sys.mjs1647
1 files changed, 1647 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);
+ },
+};