/* 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); }, };