diff options
Diffstat (limited to 'toolkit/components/pictureinpicture/PictureInPicture.jsm')
-rw-r--r-- | toolkit/components/pictureinpicture/PictureInPicture.jsm | 827 |
1 files changed, 827 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.jsm b/toolkit/components/pictureinpicture/PictureInPicture.jsm new file mode 100644 index 0000000000..f1f17fd3f0 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPicture.jsm @@ -0,0 +1,827 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "PictureInPicture", + "PictureInPictureParent", + "PictureInPictureToggleParent", + "PictureInPictureLauncherParent", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml"; +var PLAYER_FEATURES = + "chrome,titlebar=yes,alwaysontop,lockaspectratio,resizable"; +/* Don't use dialog on Gtk as it adds extra border and titlebar to PIP window */ +if (!AppConstants.MOZ_WIDGET_GTK) { + PLAYER_FEATURES += ",dialog"; +} +const WINDOW_TYPE = "Toolkit:PictureInPicture"; +const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled"; +const MULTI_PIP_ENABLED_PREF = + "media.videocontrols.picture-in-picture.allow-multiple"; +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; + +/** + * If closing the Picture-in-Picture player window occurred for a reason that + * we can easily detect (user clicked on the close button, originating tab unloaded, + * user clicked on the unpip button), that will be stashed in gCloseReasons so that + * we can note it in Telemetry when the window finally unloads. + */ +let gCloseReasons = new WeakMap(); + +/** + * Tracks the number of currently open player windows for Telemetry tracking + */ +let gCurrentPlayerCount = 0; + +/** + * To differentiate windows in the Telemetry Event Log, each Picture-in-Picture + * player window is given a unique ID. + */ +let gNextWindowID = 0; + +class PictureInPictureLauncherParent extends JSWindowActorParent { + receiveMessage(aMessage) { + switch (aMessage.name) { + case "PictureInPicture:Request": { + let videoData = aMessage.data; + PictureInPicture.handlePictureInPictureRequest(this.manager, videoData); + break; + } + } + } +} + +class PictureInPictureToggleParent extends JSWindowActorParent { + receiveMessage(aMessage) { + let browsingContext = aMessage.target.browsingContext; + let browser = browsingContext.top.embedderElement; + switch (aMessage.name) { + case "PictureInPicture:OpenToggleContextMenu": { + let win = browser.ownerGlobal; + PictureInPicture.openToggleContextMenu(win, aMessage.data); + break; + } + } + } +} + +/** + * This module is responsible for creating a Picture in Picture window to host + * a clone of a video element running in web content. + */ +class PictureInPictureParent extends JSWindowActorParent { + receiveMessage(aMessage) { + switch (aMessage.name) { + case "PictureInPicture:Resize": { + let videoData = aMessage.data; + PictureInPicture.resizePictureInPictureWindow(videoData, this); + break; + } + case "PictureInPicture:Close": { + /** + * Content has requested that its Picture in Picture window go away. + */ + let reason = aMessage.data.reason; + + if (PictureInPicture.isMultiPipEnabled) { + PictureInPicture.closeSinglePipWindow({ reason, actorRef: this }); + } else { + PictureInPicture.closeAllPipWindows({ reason }); + } + break; + } + case "PictureInPicture:Playing": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.setIsPlayingState(true); + } + break; + } + case "PictureInPicture:Paused": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.setIsPlayingState(false); + } + break; + } + case "PictureInPicture:Muting": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.setIsMutedState(true); + } + break; + } + case "PictureInPicture:Unmuting": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.setIsMutedState(false); + } + break; + } + } + } +} + +/** + * This module is responsible for creating a Picture in Picture window to host + * a clone of a video element running in web content. + */ + +var PictureInPicture = { + // Maps PictureInPictureParent actors to their corresponding PiP player windows + weakPipToWin: new WeakMap(), + + // Maps PiP player windows to their originating content's browser + weakWinToBrowser: new WeakMap(), + + /** + * Returns the player window if one exists and if it hasn't yet been closed. + * + * @param pipActorRef (PictureInPictureParent) + * Reference to the calling PictureInPictureParent actor + * + * @return {DOM Window} the player window if it exists and is not in the + * process of being closed. Returns null otherwise. + */ + getWeakPipPlayer(pipActorRef) { + let playerWin = this.weakPipToWin.get(pipActorRef); + if (!playerWin || playerWin.closed) { + return null; + } + return playerWin; + }, + + /** + * Called when the browser UI handles the View:PictureInPicture command via + * the keyboard. + */ + onCommand(event) { + if (!Services.prefs.getBoolPref(PIP_ENABLED_PREF, false)) { + return; + } + + let win = event.target.ownerGlobal; + let browser = win.gBrowser.selectedBrowser; + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "PictureInPictureLauncher" + ); + actor.sendAsyncMessage("PictureInPicture:KeyToggle"); + }, + + async focusTabAndClosePip(window, pipActor) { + let browser = this.weakWinToBrowser.get(window); + if (!browser) { + return; + } + + let gBrowser = browser.ownerGlobal.gBrowser; + let tab = gBrowser.getTabForBrowser(browser); + + gBrowser.selectedTab = tab; + await this.closeSinglePipWindow({ reason: "unpip", actorRef: pipActor }); + }, + + /** + * Remove attribute which enables pip icon in tab + * + * @param window {Window} + * A PictureInPicture player's window, used to resolve the player's + * associated originating content browser + */ + clearPipTabIcon(window) { + const browser = this.weakWinToBrowser.get(window); + if (!browser) { + return; + } + + // see if no other pip windows are open for this content browser + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if ( + win !== window && + this.weakWinToBrowser.has(win) && + this.weakWinToBrowser.get(win) === browser + ) { + return; + } + } + + let gBrowser = browser.ownerGlobal.gBrowser; + let tab = gBrowser.getTabForBrowser(browser); + if (tab) { + tab.removeAttribute("pictureinpicture"); + } + }, + + /** + * Closes and waits for passed PiP player window to finish closing. + * + * @param pipWin {Window} + * Player window to close + */ + async closePipWindow(pipWin) { + if (pipWin.closed) { + return; + } + let closedPromise = new Promise(resolve => { + pipWin.addEventListener("unload", resolve, { once: true }); + }); + pipWin.close(); + await closedPromise; + }, + + /** + * Closes a single PiP window. Used exclusively in conjunction with support + * for multiple PiP windows + * + * @param {Object} closeData + * Additional data required to complete a close operation on a PiP window + * @param {PictureInPictureParent} closeData.actorRef + * The PictureInPictureParent actor associated with the PiP window being closed + * @param {string} closeData.reason + * The reason for closing this PiP window + */ + async closeSinglePipWindow(closeData) { + const { reason, actorRef } = closeData; + const win = this.getWeakPipPlayer(actorRef); + if (!win) { + return; + } + + await this.closePipWindow(win); + gCloseReasons.set(win, reason); + }, + + /** + * Find and close any pre-existing Picture in Picture windows. Used exclusively + * when multiple PiP window support is turned off. All windows can be closed because it + * is assumed that only 1 window is open when it is called. + * + * @param {Object} closeData + * Additional data required to complete a close operation on a PiP window + * @param {string} closeData.reason + * The reason why this PiP is being closed + */ + async closeAllPipWindows(closeData) { + const { reason } = closeData; + + // This uses an enumerator, but there really should only be one of + // these things. + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if (win.closed) { + continue; + } + let closedPromise = new Promise(resolve => { + win.addEventListener("unload", resolve, { once: true }); + }); + gCloseReasons.set(win, reason); + win.close(); + await closedPromise; + } + }, + + /** + * A request has come up from content to open a Picture in Picture + * window. + * + * @param wgp (WindowGlobalParent) + * The WindowGlobalParent that is requesting the Picture in Picture + * window. + * + * @param videoData (object) + * An object containing the following properties: + * + * videoHeight (int): + * The preferred height of the video. + * + * videoWidth (int): + * The preferred width of the video. + * + * @returns Promise + * Resolves once the Picture in Picture window has been created, and + * the player component inside it has finished loading. + */ + async handlePictureInPictureRequest(wgp, videoData) { + if (!this.isMultiPipEnabled) { + // If there's a pre-existing PiP window, close it first if multiple + // pips are disabled + await this.closeAllPipWindows({ reason: "new-pip" }); + + gCurrentPlayerCount = 1; + } else { + // track specific number of open pip players if multi pip is + // enabled + + gCurrentPlayerCount += 1; + } + + Services.telemetry.scalarSetMaximum( + "pictureinpicture.most_concurrent_players", + gCurrentPlayerCount + ); + + let browser = wgp.browsingContext.top.embedderElement; + let parentWin = browser.ownerGlobal; + + let win = await this.openPipWindow(parentWin, videoData); + win.setIsPlayingState(videoData.playing); + win.setIsMutedState(videoData.isMuted); + + // set attribute which shows pip icon in tab + let tab = parentWin.gBrowser.getTabForBrowser(browser); + tab.setAttribute("pictureinpicture", true); + + win.setupPlayer(gNextWindowID.toString(), wgp, videoData.videoRef); + gNextWindowID++; + + this.weakWinToBrowser.set(win, browser); + + Services.prefs.setBoolPref( + "media.videocontrols.picture-in-picture.video-toggle.has-used", + true + ); + }, + + /** + * unload event has been called in player.js, cleanup our preserved + * browser object. + */ + unload(window) { + TelemetryStopwatch.finish( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + window + ); + + let reason = gCloseReasons.get(window) || "other"; + Services.telemetry.keyedScalarAdd( + "pictureinpicture.closed_method", + reason, + 1 + ); + gCurrentPlayerCount -= 1; + // Saves the location of the Picture in Picture window + this.savePosition(window); + this.clearPipTabIcon(window); + }, + + /** + * Open a Picture in Picture window on the same screen as parentWin, + * sized based on the information in videoData. + * + * @param parentWin (chrome window) + * The window hosting the browser that requested the Picture in + * Picture window. + * + * @param videoData (object) + * An object containing the following properties: + * + * videoHeight (int): + * The preferred height of the video. + * + * videoWidth (int): + * The preferred width of the video. + * + * @param actorReference (PictureInPictureParent) + * Reference to the calling PictureInPictureParent + * + * @returns Promise + * Resolves once the window has opened and loaded the player component. + */ + async openPipWindow(parentWin, videoData) { + let { top, left, width, height } = this.fitToScreen(parentWin, videoData); + + let features = + `${PLAYER_FEATURES},top=${top},left=${left},` + + `outerWidth=${width},outerHeight=${height}`; + + let pipWindow = Services.ww.openWindow( + parentWin, + PLAYER_URI, + null, + features, + null + ); + + TelemetryStopwatch.start( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + pipWindow, + { + inSeconds: true, + } + ); + + return new Promise(resolve => { + pipWindow.addEventListener( + "load", + () => { + resolve(pipWindow); + }, + { once: true } + ); + }); + }, + + /** + * This function tries to restore the last known Picture-in-Picture location + * and size. If those values are unknown or offscreen, then a default + * location and size is used. + * + * @param requestingWin (chrome window|player window) + * The window hosting the browser that requested the Picture in + * Picture window. If this is an existing player window then the returned + * player size and position will be determined based on the existing + * player window's size and position. + * + * @param videoData (object) + * An object containing the following properties: + * + * videoHeight (int): + * The preferred height of the video. + * + * videoWidth (int): + * The preferred width of the video. + * + * @returns (object) + * The size and position for the player window. + * + * top (int): + * The top position for the player window. + * + * left (int): + * The left position for the player window. + * + * width (int): + * The width of the player window. + * + * height (int): + * The height of the player window. + */ + fitToScreen(requestingWin, videoData) { + let { videoHeight, videoWidth } = videoData; + + const isPlayer = requestingWin.document.location.href == PLAYER_URI; + + let top, left, width, height; + if (isPlayer) { + // requestingWin is a PiP player, conserve its dimensions in this case + left = requestingWin.screenX; + top = requestingWin.screenY; + width = requestingWin.innerWidth; + height = requestingWin.innerHeight; + } else { + // requestingWin is a content window, load last PiP's dimensions + ({ top, left, width, height } = this.loadPosition()); + } + + // Check that previous location and size were loaded + if (!isNaN(top) && !isNaN(left) && !isNaN(width) && !isNaN(height)) { + // Center position of PiP window + let centerX = left + width / 2; + let centerY = top + height / 2; + + // Get the screen of the last PiP using the center of the PiP + // window to check. + // PiP screen will be the default screen if the center was + // not on a screen. + let PiPScreen = this.getWorkingScreen(centerX, centerY); + + // We have the screen, now we will get the dimensions of the screen + let [ + PiPScreenLeft, + PiPScreenTop, + PiPScreenWidth, + PiPScreenHeight, + ] = this.getAvailScreenSize(PiPScreen); + + // Check that the center of the last PiP location is within the screen limits + // If it's not, then we will use the default size and position + if ( + PiPScreenLeft <= centerX && + centerX <= PiPScreenLeft + PiPScreenWidth && + PiPScreenTop <= centerY && + centerY <= PiPScreenTop + PiPScreenHeight + ) { + let oldWidth = width; + + // The new PiP window will keep the height of the old + // PiP window and adjust the width to the correct ratio + width = Math.round((height * videoWidth) / videoHeight); + + // Minimum window size on Windows is 136 + if (AppConstants.platform == "win") { + width = 136 > width ? 136 : width; + } + + // WIGGLE_ROOM allows the PiP window to be within 5 pixels of the right + // side of the screen to stay snapped to the right side + const WIGGLE_ROOM = 5; + // If the PiP window was right next to the right side of the screen + // then move the PiP window to the right the same distance that + // the width changes from previous width to current width + let rightScreen = PiPScreenLeft + PiPScreenWidth; + let distFromRight = rightScreen - (left + width); + if ( + 0 < distFromRight && + distFromRight <= WIGGLE_ROOM + (oldWidth - width) + ) { + left += distFromRight; + } + + // Checks if some of the PiP window is off screen and + // if so it will adjust to move everything on screen + if (left < PiPScreenLeft) { + // off the left of the screen + // slide right + left += PiPScreenLeft - left; + } + if (top < PiPScreenTop) { + // off the top of the screen + // slide down + top += PiPScreenTop - top; + } + if (left + width > PiPScreenLeft + PiPScreenWidth) { + // off the right of the screen + // slide left + left += PiPScreenLeft + PiPScreenWidth - left - width; + } + if (top + height > PiPScreenTop + PiPScreenHeight) { + // off the bottom of the screen + // slide up + top += PiPScreenTop + PiPScreenHeight - top - height; + } + return { top, left, width, height }; + } + } + + // We don't have the size or position of the last PiP window, so fall + // back to calculating the default location. + let screen = this.getWorkingScreen( + requestingWin.screenX, + requestingWin.screenY, + requestingWin.innerWidth, + requestingWin.innerHeight + ); + let [ + screenLeft, + screenTop, + screenWidth, + screenHeight, + ] = this.getAvailScreenSize(screen); + + // The Picture in Picture window will be a maximum of a quarter of + // the screen height, and a third of the screen width. + const MAX_HEIGHT = screenHeight / 4; + const MAX_WIDTH = screenWidth / 3; + + width = videoWidth; + height = videoHeight; + let aspectRatio = videoWidth / videoHeight; + + if (videoHeight > MAX_HEIGHT || videoWidth > MAX_WIDTH) { + // We're bigger than the max. + // Take the largest dimension and clamp it to the associated max. + // Recalculate the other dimension to maintain aspect ratio. + if (videoWidth >= videoHeight) { + // We're clamping the width, so the height must be adjusted to match + // the original aspect ratio. Since aspect ratio is width over height, + // that means we need to _divide_ the MAX_WIDTH by the aspect ratio to + // calculate the appropriate height. + width = MAX_WIDTH; + height = Math.round(MAX_WIDTH / aspectRatio); + } else { + // We're clamping the height, so the width must be adjusted to match + // the original aspect ratio. Since aspect ratio is width over height, + // this means we need to _multiply_ the MAX_HEIGHT by the aspect ratio + // to calculate the appropriate width. + height = MAX_HEIGHT; + width = Math.round(MAX_HEIGHT * aspectRatio); + } + } + + // Now that we have the dimensions of the video, we need to figure out how + // to position it in the bottom right corner. Since we know the width of the + // available rect, we need to subtract the dimensions of the window we're + // opening to get the top left coordinates that openWindow expects. + // + // In event that the user has multiple displays connected, we have to + // calculate the top-left coordinate of the new window in absolute + // coordinates that span the entire display space, since this is what the + // openWindow expects for its top and left feature values. + // + // The screenWidth and screenHeight values only tell us the available + // dimensions on the screen that the parent window is on. We add these to + // the screenLeft and screenTop values, which tell us where this screen is + // located relative to the "origin" in absolute coordinates. + let isRTL = Services.locale.isAppLocaleRTL; + left = isRTL ? screenLeft : screenLeft + screenWidth - width; + top = screenTop + screenHeight - height; + + return { top, left, width, height }; + }, + + resizePictureInPictureWindow(videoData, actorRef) { + let win = this.getWeakPipPlayer(actorRef); + + if (!win) { + return; + } + + let { top, left, width, height } = this.fitToScreen(win, videoData); + win.resizeTo(width, height); + win.moveTo(left, top); + }, + + openToggleContextMenu(window, data) { + let document = window.document; + let popup = document.getElementById("pictureInPictureToggleContextMenu"); + + // We synthesize a new MouseEvent to propagate the inputSource to the + // subsequently triggered popupshowing event. + let newEvent = document.createEvent("MouseEvent"); + newEvent.initNSMouseEvent( + "contextmenu", + true, + true, + null, + 0, + data.screenX, + data.screenY, + 0, + 0, + false, + false, + false, + false, + 0, + null, + 0, + data.mozInputSource + ); + popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent); + }, + + hideToggle() { + Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false); + }, + + /** + * This function takes a screen and will return the left, top, width and + * height of the screen + * @param screen + * The screen we need to get the sizec and coordinates of + * + * @returns array + * Size and location of screen + * + * screenLeft.value (int): + * The left position for the screen. + * + * screenTop.value (int): + * The top position for the screen. + * + * screenWidth.value (int): + * The width of the screen. + * + * screenHeight.value (int): + * The height of the screen. + */ + getAvailScreenSize(screen) { + let screenLeft = {}, + screenTop = {}, + screenWidth = {}, + screenHeight = {}; + screen.GetAvailRectDisplayPix( + screenLeft, + screenTop, + screenWidth, + screenHeight + ); + let fullLeft = {}, + fullTop = {}, + fullWidth = {}, + fullHeight = {}; + screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight); + + // We have to divide these dimensions by the CSS scale factor for the + // display in order for the video to be positioned correctly on displays + // that are not at a 1.0 scaling. + let scaleFactor = screen.contentsScaleFactor / screen.defaultCSSScaleFactor; + screenWidth.value *= scaleFactor; + screenHeight.value *= scaleFactor; + screenLeft.value = + (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value; + screenTop.value = + (screenTop.value - fullTop.value) * scaleFactor + fullTop.value; + + return [ + screenLeft.value, + screenTop.value, + screenWidth.value, + screenHeight.value, + ]; + }, + + /** + * This function takes in a left and top value and returns the screen they + * are located on. + * If the left and top are not on any screen, it will return the + * default screen + * @param left + * left or x coordinate + * @param top + * top or y coordinate + * + * @returns screen + * the screen the left and top are on otherwise, default screen + */ + getWorkingScreen(left, top, width = 1, height = 1) { + // Get the screen manager + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + // use screenForRect to get screen + // this returns the default screen if left and top are not + // on any screen + let screen = screenManager.screenForRect(left, top, width, height); + + return screen; + }, + + /** + * Saves position and size of Picture-in-Picture window + * @param win The Picture-in-Picture window + */ + savePosition(win) { + let xulStore = Services.xulStore; + + let left = win.screenX; + let top = win.screenY; + let width = win.innerWidth; + let height = win.innerHeight; + + xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", left); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", top); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", width); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", height); + }, + + /** + * Load last Picture in Picture location and size + * @returns object + * The size and position of the last Picture in Picture window. + * + * top (int): + * The top position for the last player window. + * Otherwise NaN + * + * left (int): + * The left position for the last player window. + * Otherwise NaN + * + * width (int): + * The width of the player last window. + * Otherwise NaN + * + * height (int): + * The height of the player last window. + * Otherwise NaN + */ + loadPosition() { + let xulStore = Services.xulStore; + + let left = parseInt( + xulStore.getValue(PLAYER_URI, "picture-in-picture", "left") + ); + let top = parseInt( + xulStore.getValue(PLAYER_URI, "picture-in-picture", "top") + ); + let width = parseInt( + xulStore.getValue(PLAYER_URI, "picture-in-picture", "width") + ); + let height = parseInt( + xulStore.getValue(PLAYER_URI, "picture-in-picture", "height") + ); + + return { top, left, width, height }; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + PictureInPicture, + "isMultiPipEnabled", + MULTI_PIP_ENABLED_PREF, + false +); |