From 52278792ca9419088e7ea7726ef92f88ca51b2d8 Mon Sep 17 00:00:00 2001 From: Mike Conley Date: Tue, 12 Feb 2019 02:34:38 +0000 Subject: [PATCH] Bug 1520329 - Add messaging infrastructure for opening videos in a Picture in Picture window. r=Felipe Differential Revision: https://phabricator.services.mozilla.com/D16903 --HG-- extra : moz-landing-system : lando --- browser/actors/ContextMenuChild.jsm | 7 +- browser/app/profile/firefox.js | 3 + browser/base/content/browser-context.inc | 7 + browser/base/content/nsContextMenu.js | 6 + browser/components/nsBrowserGlue.js | 3 + toolkit/actors/PictureInPictureChild.jsm | 122 +++++++++++++ toolkit/actors/moz.build | 5 + toolkit/components/moz.build | 3 + .../pictureinpicture/PictureInPicture.jsm | 167 ++++++++++++++++++ .../pictureinpicture/content/player.js | 30 ++++ .../pictureinpicture/content/player.xhtml | 25 +++ toolkit/components/pictureinpicture/jar.mn | 8 + toolkit/components/pictureinpicture/moz.build | 11 ++ toolkit/modules/ActorManagerParent.jsm | 16 ++ toolkit/themes/shared/jar.inc.mn | 1 + .../themes/shared/pictureinpicture/player.css | 18 ++ 16 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 toolkit/actors/PictureInPictureChild.jsm create mode 100644 toolkit/components/pictureinpicture/PictureInPicture.jsm create mode 100644 toolkit/components/pictureinpicture/content/player.js create mode 100644 toolkit/components/pictureinpicture/content/player.xhtml create mode 100644 toolkit/components/pictureinpicture/jar.mn create mode 100644 toolkit/components/pictureinpicture/moz.build create mode 100644 toolkit/themes/shared/pictureinpicture/player.css diff --git a/browser/actors/ContextMenuChild.jsm b/browser/actors/ContextMenuChild.jsm index 558f68317d54..0d85882d37e7 100644 --- a/browser/actors/ContextMenuChild.jsm +++ b/browser/actors/ContextMenuChild.jsm @@ -94,7 +94,12 @@ const messageListeners = { if (this.content.document.fullscreenEnabled) { media.requestFullscreen(); } - + break; + case "pictureinpicture": + let event = new this.content.CustomEvent("MozTogglePictureInPicture", { + bubbles: true, + }, this.content); + media.dispatchEvent(event); break; } } diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index e811c4fe382c..fdb2541f7603 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1486,6 +1486,9 @@ pref("media.autoplay.block-webaudio", true); pref("media.autoplay.block-webaudio", false); #endif +#ifdef NIGHTLY_BUILD +pref("media.videocontrols.picture-in-picture.enabled", false); +#endif // Play with different values of the decay time and get telemetry, // 0 means to randomize (and persist) the experiment value in users' profiles, diff --git a/browser/base/content/browser-context.inc b/browser/base/content/browser-context.inc index 31b50e3c29a0..fe67ad56d439 100644 --- a/browser/base/content/browser-context.inc +++ b/browser/base/content/browser-context.inc @@ -175,6 +175,13 @@ accesskey="&leaveDOMFullScreen.accesskey;" label="&leaveDOMFullScreen.label;" oncommand="gContextMenu.leaveDOMFullScreen();"/> +#ifdef NIGHTLY_BUILD + + +#endif { + this.mm.addEventListener("load", resolve, { + once: true, + mozSystemGroup: true, + capture: true, + }); + }); + } + + let stream = originatingVideo.mozCaptureStream(); + + let doc = this.content.document; + let playerVideo = doc.createElement("video"); + playerVideo.srcObject = stream; + playerVideo.removeAttribute("controls"); + playerVideo.setAttribute("autoplay", "true"); + + // Mute the video and rely on the originating video's audio playback. + // This way, we sidestep the AutoplayPolicy blocking stuff. + playerVideo.muted = true; + + // Force the player video to assume maximum height and width of the + // containing window + playerVideo.style.height = "100vh"; + playerVideo.style.width = "100vw"; + + // And now try to get rid of as much surrounding whitespace as possible. + playerVideo.style.margin = "0"; + doc.body.style.overflow = "hidden"; + doc.body.style.margin = "0"; + + playerVideo.play(); + + // A little hack to make the current frame show up in the player + if (originatingVideo.paused) { + await originatingVideo.play(); + await originatingVideo.pause(); + } + + doc.body.appendChild(playerVideo); + + let originatingWindow = originatingVideo.ownerGlobal; + originatingWindow.addEventListener("unload", (e) => { + this.closePictureInPicture(originatingVideo); + }, { once: true }); + + this.content.addEventListener("unload", () => { + gWeakVideo = null; + }, { once: true }); + } +} diff --git a/toolkit/actors/moz.build b/toolkit/actors/moz.build index c2a1820a7912..e646376831c7 100644 --- a/toolkit/actors/moz.build +++ b/toolkit/actors/moz.build @@ -40,3 +40,8 @@ FINAL_TARGET_FILES.actors += [ 'WebNavigationChild.jsm', 'ZoomChild.jsm', ] + +if CONFIG['NIGHTLY_BUILD']: + FINAL_TARGET_FILES.actors += [ + 'PictureInPictureChild.jsm', + ] diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build index 50cc89c3c2f9..e92b82eb448b 100644 --- a/toolkit/components/moz.build +++ b/toolkit/components/moz.build @@ -87,6 +87,9 @@ if CONFIG['MOZ_BUILD_APP'] != 'mobile/android': if CONFIG['NS_PRINTING']: DIRS += ['printing'] + if CONFIG['NIGHTLY_BUILD']: + DIRS += ['pictureinpicture'] + if CONFIG['BUILD_CTYPES']: DIRS += ['ctypes'] diff --git a/toolkit/components/pictureinpicture/PictureInPicture.jsm b/toolkit/components/pictureinpicture/PictureInPicture.jsm new file mode 100644 index 000000000000..e7e60809f868 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPicture.jsm @@ -0,0 +1,167 @@ +/* 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"]; + +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml"; +const PLAYER_FEATURES = `chrome,titlebar=no,alwaysontop,resizable`; +const WINDOW_TYPE = "Toolkit:PictureInPicture"; + +/** + * 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 = { + // Listeners are added in nsBrowserGlue.js lazily + receiveMessage(aMessage) { + let browser = aMessage.target; + + switch (aMessage.name) { + case "PictureInPicture:Request": { + let videoData = aMessage.data; + this.handlePictureInPictureRequest(browser, videoData); + break; + } + case "PictureInPicture:Close": { + /** + * Content has requested that its Picture in Picture window go away. + */ + this.closePipWindow(); + break; + } + } + }, + + /** + * Find and close any pre-existing Picture in Picture windows. + */ + closePipWindow() { + // 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; + } + + win.close(); + } + }, + + /** + * A request has come up from content to open a Picture in Picture + * window. + * + * @param browser (xul:browser) + * The browser 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(browser, videoData) { + let parentWin = browser.ownerGlobal; + this.closePipWindow(); + let win = await this.openPipWindow(parentWin, videoData); + win.setupPlayer(browser, videoData); + }, + + /** + * 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. + * + * @returns Promise + * Resolves once the window has opened and loaded the player component. + */ + async openPipWindow(parentWin, videoData) { + let { videoHeight, videoWidth } = videoData; + + // The Picture in Picture window will open on the same display as the + // originating window, and anchor to the bottom right. + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"] + .getService(Ci.nsIScreenManager); + let screen = screenManager.screenForRect(parentWin.screenX, + parentWin.screenY, 1, 1); + + // Now that we have the right screen, let's see how much available + // real-estate there is for us to work with. + let screenLeft = {}, screenTop = {}, screenWidth = {}, screenHeight = {}; + screen.GetAvailRectDisplayPix(screenLeft, screenTop, screenWidth, + screenHeight); + + // For now, 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.value / 4; + const MAX_WIDTH = screenWidth.value / 3; + + let resultWidth = videoWidth; + let resultHeight = videoHeight; + + if (videoHeight > MAX_HEIGHT || videoWidth > MAX_WIDTH) { + let aspectRatio = videoWidth / videoHeight; + // 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. + resultWidth = MAX_WIDTH; + resultHeight = Math.floor(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. + resultHeight = MAX_HEIGHT; + resultWidth = Math.floor(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. + let pipLeft = screenWidth.value - resultWidth; + let pipTop = screenHeight.value - resultHeight; + let features = `${PLAYER_FEATURES},top=${pipTop},left=${pipLeft},` + + `width=${resultWidth},height=${resultHeight}`; + + let pipWindow = + Services.ww.openWindow(parentWin, PLAYER_URI, null, features, null); + + return new Promise(resolve => { + pipWindow.addEventListener("load", () => { + resolve(pipWindow); + }, { once: true }); + }); + }, +}; diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js new file mode 100644 index 000000000000..5c957cbe97e8 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.js @@ -0,0 +1,30 @@ +/* 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/. */ + +async function setupPlayer(originatingBrowser, videoData) { + window.windowUtils.setChromeMargin(0, 0, 0, 0); + let holder = document.querySelector(".player-holder"); + let browser = document.getElementById("browser"); + browser.remove(); + + browser.setAttribute("nodefaultsrc", "true"); + browser.sameProcessAsFrameLoader = originatingBrowser.frameLoader; + holder.appendChild(browser); + + browser.loadURI("about:blank", { + triggeringPrincipal: originatingBrowser.contentPrincipal, + }); + + let mm = browser.frameLoader.messageManager; + mm.sendAsyncMessage("PictureInPicture:SetupPlayer"); + + // If the content process hosting the video crashes, let's + // just close the window for now. + browser.addEventListener("oop-browser-crashed", () => { + window.close(); + }); + + await window.promiseDocumentFlushed(() => {}); + browser.style.MozWindowDragging = "drag"; +} diff --git a/toolkit/components/pictureinpicture/content/player.xhtml b/toolkit/components/pictureinpicture/content/player.xhtml new file mode 100644 index 000000000000..76b3f7217830 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.xhtml @@ -0,0 +1,25 @@ + + + + %htmlDTD; +]> + + + + + + + + +
+ +
+ + diff --git a/toolkit/components/pictureinpicture/jar.mn b/toolkit/components/pictureinpicture/jar.mn new file mode 100644 index 000000000000..a9f264de2759 --- /dev/null +++ b/toolkit/components/pictureinpicture/jar.mn @@ -0,0 +1,8 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: + content/global/pictureinpicture/player.xhtml (content/player.xhtml) + content/global/pictureinpicture/player.js (content/player.js) diff --git a/toolkit/components/pictureinpicture/moz.build b/toolkit/components/pictureinpicture/moz.build new file mode 100644 index 000000000000..ee46b20440e7 --- /dev/null +++ b/toolkit/components/pictureinpicture/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ['jar.mn'] + +EXTRA_JS_MODULES += [ + 'PictureInPicture.jsm', +] diff --git a/toolkit/modules/ActorManagerParent.jsm b/toolkit/modules/ActorManagerParent.jsm index 03217b43d6ab..f8c8c6858a4e 100644 --- a/toolkit/modules/ActorManagerParent.jsm +++ b/toolkit/modules/ActorManagerParent.jsm @@ -97,6 +97,7 @@ var EXPORTED_SYMBOLS = ["ActorManagerParent"]; const {ExtensionUtils} = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm"); const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); const {DefaultMap} = ExtensionUtils; @@ -337,6 +338,21 @@ let ACTORS = { }, }; +if (AppConstants.NIGHTLY_BUILD) { + ACTORS.PictureInPicture = { + child: { + module: "resource://gre/actors/PictureInPictureChild.jsm", + events: { + "MozTogglePictureInPicture": {capture: true, wantUntrusted: true}, + }, + + messages: [ + "PictureInPicture:SetupPlayer", + ], + }, + }; +} + class ActorSet { constructor(group, actorSide) { this.group = group; diff --git a/toolkit/themes/shared/jar.inc.mn b/toolkit/themes/shared/jar.inc.mn index 83428d6a7341..0c73dc7db9c7 100644 --- a/toolkit/themes/shared/jar.inc.mn +++ b/toolkit/themes/shared/jar.inc.mn @@ -105,3 +105,4 @@ toolkit.jar: skin/classic/global/plugins/contentPluginBlocked.png (../../shared/plugins/contentPluginBlocked.png) skin/classic/global/plugins/contentPluginCrashed.png (../../shared/plugins/contentPluginCrashed.png) skin/classic/global/plugins/contentPluginStripe.png (../../shared/plugins/contentPluginStripe.png) + skin/classic/global/pictureinpicture/player.css (../../shared/pictureinpicture/player.css) diff --git a/toolkit/themes/shared/pictureinpicture/player.css b/toolkit/themes/shared/pictureinpicture/player.css new file mode 100644 index 000000000000..3b5dd7971f39 --- /dev/null +++ b/toolkit/themes/shared/pictureinpicture/player.css @@ -0,0 +1,18 @@ +/* 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/. */ + +body { + margin: 0; +} + +.player-holder { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +browser { + flex: 1; +}