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
This commit is contained in:
Mike Conley 2019-02-12 02:34:38 +00:00
Родитель 1677d55556
Коммит 52278792ca
16 изменённых файлов: 431 добавлений и 1 удалений

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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,

Просмотреть файл

@ -175,6 +175,13 @@
accesskey="&leaveDOMFullScreen.accesskey;"
label="&leaveDOMFullScreen.label;"
oncommand="gContextMenu.leaveDOMFullScreen();"/>
#ifdef NIGHTLY_BUILD
<!-- Don't forget to add a properly localized label and access key
before letting this ride up to beta. -->
<menuitem id="context-video-pictureinpicture"
label="Picture in Picture"
oncommand="gContextMenu.mediaCommand('pictureinpicture');"/>
#endif
<menuseparator id="context-media-sep-commands"/>
<menuitem id="context-reloadimage"
label="&reloadImageCmd.label;"

Просмотреть файл

@ -670,6 +670,12 @@ nsContextMenu.prototype = {
this.showItem("context-media-showcontrols", onMedia && !this.target.controls);
this.showItem("context-media-hidecontrols", this.target.controls && (this.onVideo || (this.onAudio && !this.inSyntheticDoc)));
this.showItem("context-video-fullscreen", this.onVideo && !this.target.ownerDocument.fullscreen);
if (AppConstants.NIGHTLY_BUILD) {
let shouldDisplay = Services.prefs.getBoolPref("media.videocontrols.picture-in-picture.enabled") &&
this.onVideo &&
!this.target.ownerDocument.fullscreen;
this.showItem("context-video-pictureinpicture", shouldDisplay);
}
this.showItem("context-media-eme-learnmore", this.onDRMMedia);
this.showItem("context-media-eme-separator", this.onDRMMedia);

Просмотреть файл

@ -415,6 +415,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
PageThumbs: "resource://gre/modules/PageThumbs.jsm",
PdfJs: "resource://pdf.js/PdfJs.jsm",
PermissionUI: "resource:///modules/PermissionUI.jsm",
PictureInPicture: "resource://gre/modules/PictureInPicture.jsm",
PingCentre: "resource:///modules/PingCentre.jsm",
PlacesBackups: "resource://gre/modules/PlacesBackups.jsm",
PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
@ -512,6 +513,8 @@ const listeners = {
"ContentSearch": ["ContentSearch"],
"FormValidation:ShowPopup": ["FormValidationHandler"],
"FormValidation:HidePopup": ["FormValidationHandler"],
"PictureInPicture:Request": ["PictureInPicture"],
"PictureInPicture:Close": ["PictureInPicture"],
"Prompt:Open": ["RemotePrompt"],
"Reader:FaviconRequest": ["ReaderParent"],
"Reader:UpdateReaderButton": ["ReaderParent"],

Просмотреть файл

@ -0,0 +1,122 @@
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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 = ["PictureInPictureChild"];
const {ActorChild} = ChromeUtils.import("resource://gre/modules/ActorChild.jsm");
var gWeakVideo = null;
class PictureInPictureChild extends ActorChild {
handleEvent(event) {
switch (event.type) {
case "MozTogglePictureInPicture": {
this.togglePictureInPicture(event.target);
break;
}
}
}
togglePictureInPicture(video) {
if (this.inPictureInPicture(video)) {
this.closePictureInPicture(video);
} else {
this.requestPictureInPicture(video);
}
}
inPictureInPicture(video) {
return gWeakVideo && gWeakVideo.get() === video;
}
closePictureInPicture() {
this.mm.sendAsyncMessage("PictureInPicture:Close", {
browingContextId: this.docShell.browsingContext.id,
});
}
requestPictureInPicture(video) {
gWeakVideo = Cu.getWeakReference(video);
this.mm.sendAsyncMessage("PictureInPicture:Request", {
videoHeight: video.videoHeight,
videoWidth: video.videoWidth,
});
}
receiveMessage(message) {
switch (message.name) {
case "PictureInPicture:SetupPlayer": {
this.setupPlayer();
break;
}
}
}
async setupPlayer() {
if (!gWeakVideo) {
this.closePictureInPicture();
}
let originatingVideo = gWeakVideo.get();
if (!originatingVideo) {
this.closePictureInPicture();
}
let webProgress = this.mm
.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebProgress);
if (webProgress.isLoadingDocument) {
await new Promise(resolve => {
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 });
}
}

Просмотреть файл

@ -40,3 +40,8 @@ FINAL_TARGET_FILES.actors += [
'WebNavigationChild.jsm',
'ZoomChild.jsm',
]
if CONFIG['NIGHTLY_BUILD']:
FINAL_TARGET_FILES.actors += [
'PictureInPictureChild.jsm',
]

Просмотреть файл

@ -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']

Просмотреть файл

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

Просмотреть файл

@ -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";
}

Просмотреть файл

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 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/. -->
<!DOCTYPE html [
<!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
%htmlDTD;
]>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
windowtype="Toolkit:PictureInPicture">
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" type="text/css"
href="chrome://global/skin/pictureinpicture/player.css"/>
<script type="application/javascript"
src="chrome://global/content/pictureinpicture/player.js"></script>
</head>
<body>
<div class="player-holder">
<xul:browser type="content" primary="true" remote="true" remoteType="web" id="browser"></xul:browser>
</div>
</body>
</html>

Просмотреть файл

@ -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)

Просмотреть файл

@ -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',
]

Просмотреть файл

@ -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;

Просмотреть файл

@ -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)

Просмотреть файл

@ -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;
}