gecko-dev/toolkit/components/pictureinpicture/PictureInPicture.jsm

858 строки
26 KiB
JavaScript

/* 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 {PictureInPictureParent} pipActorRef
* 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;
},
handleEvent(event) {
switch (event.type) {
case "TabSwapPictureInPicture": {
this.onPipSwappedBrowsers(event);
}
}
},
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);
}
}
otherTab.addEventListener("TabSwapPictureInPicture", this);
}
},
/**
* Called when the browser UI handles the View:PictureInPicture command via
* the keyboard.
*
* @param {Event} event
*/
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 {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;
}
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 {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) {
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);
tab.addEventListener("TabSwapPictureInPicture", this);
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.
*
* @param {Window} window
*/
unload(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 {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 features =
`${PLAYER_FEATURES},top=${top},left=${left},` +
`outerWidth=${width},outerHeight=${height}`;
let pipWindow = Services.ww.openWindow(
parentWin,
PLAYER_URI,
null,
features,
null
);
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.
*
* 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 };
},
/**
* 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;
}
let { top, left, width, height } = this.fitToScreen(win, videoData);
win.resizeTo(width, height);
win.moveTo(left, top);
},
/**
* 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");
// 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} 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 {int} left
* left or x coordinate
*
* @param {int} top
* 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
let screen = screenManager.screenForRect(left, top, width, height);
return screen;
},
/**
* Saves position and size of Picture-in-Picture window
* @param {Window} 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
);