Bug 1535354 - Add a toggle to trigger Picture-in-Picture that appears over top of <video> elements. Disabled by default. r=jaws,flod

Differential Revision: https://phabricator.services.mozilla.com/D25658

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Mike Conley 2019-04-05 15:51:58 +00:00
Родитель b9ac2b7da9
Коммит c4e69ae516
7 изменённых файлов: 638 добавлений и 1 удалений

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

@ -65,6 +65,9 @@ let whitelist = [
intermittent: true,
errorMessage: /Property contained reference to invalid variable.*background/i,
isFromDevTools: true},
{sourceName: /pictureinpicture\/toggle.css$/i,
errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
isFromDevTools: false},
];
if (!Services.prefs.getBoolPref("layout.css.xul-box-display-values.content.enabled")) {

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

@ -446,6 +446,9 @@ pref("media.peerconnection.sdp.rust.compare", false);
#endif
pref("media.videocontrols.picture-in-picture.enabled", false);
pref("media.videocontrols.picture-in-picture.video-toggle.enabled", false);
pref("media.videocontrols.picture-in-picture.video-toggle.flyout-enabled", false);
pref("media.videocontrols.picture-in-picture.video-toggle.flyout-wait-ms", 5000);
pref("media.webrtc.debug.trace_mask", 0);
pref("media.webrtc.debug.multi_log", false);

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

@ -4,16 +4,540 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = ["PictureInPictureChild"];
var EXPORTED_SYMBOLS = ["PictureInPictureChild", "PictureInPictureToggleChild"];
const {ActorChild} = ChromeUtils.import("resource://gre/modules/ActorChild.jsm");
ChromeUtils.defineModuleGetter(this, "DeferredTask",
"resource://gre/modules/DeferredTask.jsm");
ChromeUtils.defineModuleGetter(this, "DOMLocalization",
"resource://gre/modules/DOMLocalization.jsm");
ChromeUtils.defineModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm");
const TOGGLE_STYLESHEET = "chrome://global/skin/pictureinpicture/toggle.css";
const TOGGLE_ID = "picture-in-picture-toggle";
const FLYOUT_TOGGLE_ID = "picture-in-picture-flyout-toggle";
const FLYOUT_TOGGLE_CONTAINER = "picture-in-picture-flyout-container";
const TOGGLE_ENABLED_PREF =
"media.videocontrols.picture-in-picture.video-toggle.enabled";
const FLYOUT_ENABLED_PREF =
"media.videocontrols.picture-in-picture.video-toggle.flyout-enabled";
const FLYOUT_WAIT_MS_PREF =
"media.videocontrols.picture-in-picture.video-toggle.flyout-wait-ms";
const FLYOUT_ANIMATION_RUNTIME_MS = 400;
const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
// A weak reference to the most recent <video> in this content
// process that is being viewed in Picture-in-Picture.
var gWeakVideo = null;
// A weak reference to the content window of the most recent
// Picture-in-Picture window for this content process.
var gWeakPlayerContent = null;
// A process-global Promise that's set the first time the string for the
// flyout toggle label is requested from Fluent.
var gFlyoutLabelPromise = null;
// A process-global for the width of the toggle icon. We stash this here after
// computing it the first time to avoid repeatedly flushing styles.
var gToggleWidth = 0;
/**
* The PictureInPictureToggleChild is responsible for displaying the overlaid
* Picture-in-Picture toggle over top of <video> elements that the mouse is
* hovering.
*
* It's also responsible for showing the "flyout" version of the toggle, which
* currently displays on the first visible video per page.
*/
class PictureInPictureToggleChild extends ActorChild {
constructor(dispatcher) {
super(dispatcher);
// We need to maintain some state about various things related to the
// Picture-in-Picture toggles - however, for now, the same
// PictureInPictureToggleChild might be re-used for different documents.
// We keep the state stashed inside of this WeakMap, keyed on the document
// itself.
this.weakDocStates = new WeakMap();
this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
this.flyoutEnabled = Services.prefs.getBoolPref(FLYOUT_ENABLED_PREF);
this.flyoutWaitMs = Services.prefs.getIntPref(FLYOUT_WAIT_MS_PREF);
this.l10n = new DOMLocalization([
"toolkit/global/videocontrols.ftl",
]);
}
/**
* Returns the state for the current document referred to via
* this.content.document. If no such state exists, creates it, stores it
* and returns it.
*/
get docState() {
let state = this.weakDocStates.get(this.content.document);
if (!state) {
state = {
// A reference to the IntersectionObserver that's monitoring for videos
// to become visible.
intersectionObserver: null,
// A WeakSet of videos that are supposedly visible, according to the
// IntersectionObserver.
weakVisibleVideos: new WeakSet(),
// The number of videos that are supposedly visible, according to the
// IntersectionObserver
visibleVideos: 0,
// The DeferredTask that we'll arm every time a mousemove event occurs
// on a page where we have one or more visible videos.
mousemoveDeferredTask: null,
// A weak reference to the last video we displayed the toggle over.
weakOverVideo: null,
// A reference to the AnonymousContent returned after inserting the
// small toggle.
pipToggle: null,
// A reference to the AnonymousContent returned after inserting the
// flyout toggle.
flyoutToggle: null,
};
this.weakDocStates.set(this.content.document, state);
}
return state;
}
handleEvent(event) {
switch (event.type) {
case "canplay": {
if (this.toggleEnabled &&
event.target instanceof this.content.HTMLVideoElement &&
event.target.ownerDocument == this.content.document) {
this.registerVideo(event.target);
}
break;
}
case "click": {
let state = this.docState;
let clickedFlyout = state.flyoutToggle &&
state.flyoutToggle.getTargetIdForEvent(event) == FLYOUT_TOGGLE_ID;
let clickedToggle = state.pipToggle &&
state.pipToggle.getTargetIdForEvent(event) == TOGGLE_ID;
if (clickedFlyout || clickedToggle) {
let video = state.weakOverVideo && state.weakOverVideo.get();
if (video) {
let pipEvent =
new this.content.CustomEvent("MozTogglePictureInPicture", {
bubbles: true,
});
video.dispatchEvent(pipEvent);
this.hideFlyout();
this.onMouseLeaveVideo(video);
}
}
break;
}
case "mousemove": {
this.onMouseMove(event);
break;
}
}
}
/**
* Adds a <video> to the IntersectionObserver so that we know when it becomes
* visible.
*
* @param {Element} video The <video> element to register.
*/
registerVideo(video) {
let state = this.docState;
if (!state.intersectionObserver) {
let fn = this.onIntersection.bind(this);
state.intersectionObserver = new this.content.IntersectionObserver(fn, {
threshold: [0.0, 1.0],
});
}
state.intersectionObserver.observe(video);
}
/**
* Called by the IntersectionObserver callback once a video becomes visible.
* This adds some fine-grained checking to ensure that a sufficient amount of
* the video is visible before we consider showing the toggles on it. For now,
* that means that the entirety of the video must be in the viewport.
*
* @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
* the IntersectionObserver callback.
* @return bool Whether or not we should start tracking mousemove events for
* this registered video.
*/
worthTracking(intersectionEntry) {
let video = intersectionEntry.target;
let rect = video.ownerGlobal.windowUtils.getBoundsWithoutFlushing(video);
let intRect = intersectionEntry.intersectionRect;
return intersectionEntry.isIntersecting &&
rect.width == intRect.width &&
rect.height == intRect.height;
}
/**
* Called by the IntersectionObserver once a video crosses one of the
* thresholds dictated by the IntersectionObserver configuration.
*
* @param {Array<IntersectionEntry>} A collection of one or more
* IntersectionEntry's for <video> elements that might have entered or exited
* the viewport.
*/
onIntersection(entries) {
// The IntersectionObserver will also fire when a previously intersecting
// element is removed from the DOM. We know, however, that the node is
// still alive and referrable from the WeakSet because the
// IntersectionObserverEntry holds a strong reference to the video.
let state = this.docState;
let oldVisibleVideos = state.visibleVideos;
for (let entry of entries) {
let video = entry.target;
if (this.worthTracking(entry)) {
if (!state.weakVisibleVideos.has(video)) {
state.weakVisibleVideos.add(video);
state.visibleVideos++;
// The very first video that we notice is worth tracking, we'll show
// the flyout toggle on.
if (this.flyoutEnabled) {
this.content.requestIdleCallback(() => {
this.maybeShowFlyout(video);
});
}
}
} else if (state.weakVisibleVideos.has(video)) {
state.weakVisibleVideos.delete(video);
state.visibleVideos--;
}
}
if (!oldVisibleVideos && state.visibleVideos) {
this.content.requestIdleCallback(() => {
this.beginTrackingMouseOverVideos();
});
} else if (oldVisibleVideos && !state.visibleVideos) {
this.content.requestIdleCallback(() => {
this.stopTrackingMouseOverVideos();
});
}
}
/**
* One of the challenges of displaying this toggle is that many sites put
* things over top of <video> elements, like custom controls, or images, or
* all manner of things that might intercept mouseevents that would normally
* fire directly on the <video>. In order to properly detect when the mouse
* is over top of one of the <video> elements in this situation, we currently
* add a mousemove event handler to the entire document, and stash the most
* recent mousemove that fires. At periodic intervals, that stashed mousemove
* event is checked to see if it's hovering over one of our registered
* <video> elements.
*
* This sort of thing will not be necessary once bug 1539652 is fixed.
*/
beginTrackingMouseOverVideos() {
let state = this.docState;
if (!state.mousemoveDeferredTask) {
state.mousemoveDeferredTask = new DeferredTask(() => {
this.checkLastMouseMove();
}, MOUSEMOVE_PROCESSING_DELAY_MS);
}
this.content.document.addEventListener("mousemove", this,
{ mozSystemGroup: true });
this.content.document.addEventListener("click", this,
{ mozSystemGroup: true });
}
/**
* If we no longer have any interesting videos in the viewport, we deregister
* the mousemove and click listeners, and also remove any toggles that might
* be on the page still.
*/
stopTrackingMouseOverVideos() {
let state = this.docState;
state.mousemoveDeferredTask.disarm();
this.content.document.removeEventListener("mousemove", this,
{ mozSystemGroup: true });
this.content.document.removeEventListener("click", this,
{ mozSystemGroup: true });
let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
if (oldOverVideo) {
this.onMouseLeaveVideo(oldOverVideo);
}
}
/**
* Called for each mousemove event when we're tracking those events to
* determine if the cursor is hovering over a <video>.
*
* @param {Event} event The mousemove event.
*/
onMouseMove(event) {
let state = this.docState;
state.lastMouseMoveEvent = event;
state.mousemoveDeferredTask.arm();
}
/**
* Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
* milliseconds. Checked to see if that mousemove happens to be overtop of
* any interesting <video> elements that we want to display the toggle
* on. If so, puts the toggle on that video.
*/
checkLastMouseMove() {
let state = this.docState;
let event = state.lastMouseMoveEvent;
let { clientX, clientY } = event;
let winUtils = this.content.windowUtils;
// We use winUtils.nodesFromRect instead of document.elementsFromPoint,
// since document.elementsFromPoint always flushes layout. The 1's in that
// function call are for the size of the rect that we want, which is 1x1.
let elements = winUtils.nodesFromRect(clientX, clientY, 1, 1, 1, 1, true,
false);
for (let element of elements) {
if (state.weakVisibleVideos.has(element) &&
!element.isCloningElementVisually) {
this.onMouseOverVideo(element);
return;
}
}
let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
if (oldOverVideo) {
this.onMouseLeaveVideo(oldOverVideo);
}
}
/**
* Called once it has been determined that the mouse is overtop of a video
* that is in the viewport.
*
* @param {Element} video The video the mouse is over.
*/
onMouseOverVideo(video) {
let state = this.docState;
let oldOverVideo = state.weakOverVideo && state.weakOverVideo.get();
if (oldOverVideo && oldOverVideo == video) {
return;
}
state.weakOverVideo = Cu.getWeakReference(video);
this.moveToggleToVideo(video);
}
/**
* Called once it has been determined that the mouse is no longer overlapping
* a video that we'd previously called onMouseOverVideo with.
*
* @param {Element} video The video that the mouse left.
*/
onMouseLeaveVideo(video) {
let state = this.docState;
state.weakOverVideo = null;
state.pipToggle.setAttributeForElement(TOGGLE_ID, "hidden", "true");
}
/**
* The toggle is injected as AnonymousContent that is positioned absolutely.
* This method takes the <video> that we want to display the toggle on and
* calculates where exactly we need to position the AnonymousContent in
* absolute coordinates.
*
* @param {Element} video The video to display the toggle on.
* @param {AnonymousContent} anonymousContent The anonymousContent associated
* with the toggle about to be shown.
* @param {String} toggleID The ID of the toggle element with the CSS
* variables defining the toggle width and padding.
*
* @return {Object} with the following properties:
* {Number} top The top / y coordinate.
* {Number} left The left / x coordinate.
* {Number} width The width of the toggle icon, including padding.
*/
calculateTogglePosition(video, anonymousContent, toggleID) {
let winUtils = this.content.windowUtils;
let scrollX = {}, scrollY = {};
winUtils.getScrollXY(false, scrollX, scrollY);
let rect = winUtils.getBoundsWithoutFlushing(video);
// For now, using AnonymousContent.getComputedStylePropertyValue causes
// a style flush, so we'll cache the value in this content process the
// first time we read it. See bug 1541207.
if (!gToggleWidth) {
let widthStr = anonymousContent.getComputedStylePropertyValue(toggleID,
"--pip-toggle-icon-width-height");
let paddingStr = anonymousContent.getComputedStylePropertyValue(toggleID,
"--pip-toggle-padding");
let iconWidth = parseInt(widthStr, 0);
let iconPadding = parseInt(paddingStr, 0);
gToggleWidth = iconWidth + (2 * iconPadding);
}
let originY = rect.top + scrollY.value;
let originX = rect.left + scrollX.value;
let top = originY + (rect.height / 2 - Math.round(gToggleWidth / 2));
let left = originX + (rect.width - gToggleWidth);
return { top, left, width: gToggleWidth };
}
/**
* Puts the small "Picture-in-Picture" toggle onto the passed in video.
*
* @param {Element} video The video to display the toggle on.
*/
moveToggleToVideo(video) {
let state = this.docState;
let winUtils = this.content.windowUtils;
if (!state.pipToggle) {
try {
winUtils.loadSheetUsingURIString(TOGGLE_STYLESHEET,
winUtils.AGENT_SHEET);
} catch (e) {
// This method can fail with NS_ERROR_INVALID_ARG if the sheet is
// already loaded - for example, from the flyout toggle.
if (e.result != Cr.NS_ERROR_INVALID_ARG) {
throw e;
}
}
let toggle = this.content.document.createElement("button");
toggle.classList.add("picture-in-picture-toggle-button");
toggle.id = TOGGLE_ID;
let icon = this.content.document.createElement("div");
icon.classList.add("icon");
toggle.appendChild(icon);
state.pipToggle = this.content.document.insertAnonymousContent(toggle);
}
let { top, left } = this.calculateTogglePosition(video, state.pipToggle,
TOGGLE_ID);
let styles = `
top: ${top}px;
left: ${left}px;
`;
let toggle = state.pipToggle;
toggle.setAttributeForElement(TOGGLE_ID, "style", styles);
// The toggle might have been hidden after a previous appearance.
toggle.removeAttributeForElement(TOGGLE_ID, "hidden");
}
/**
* Lazy getter that returns a Promise that resolves to the flyout toggle
* label string. Sets a process-global variable to the Promise so that
* subsequent calls within the same process don't cause us to go through
* the Fluent look-up path again.
*/
get flyoutLabel() {
if (gFlyoutLabelPromise) {
return gFlyoutLabelPromise;
}
gFlyoutLabelPromise =
this.l10n.formatValue("picture-in-picture-flyout-toggle");
return gFlyoutLabelPromise;
}
/**
* If configured to, will display the "Picture-in-Picture" flyout toggle on
* the passed-in video. This is an asynchronous function that handles the
* entire lifecycle of the flyout animation. If a flyout toggle has already
* been seen on this page, this function does nothing.
*
* @param {Element} video The video to display the flyout on.
*
* @return {Promise}
* @resolves {undefined} Once the flyout toggle animation has completed.
*/
async maybeShowFlyout(video) {
let state = this.docState;
if (state.flyoutToggle) {
return;
}
let winUtils = this.content.windowUtils;
try {
winUtils.loadSheetUsingURIString(TOGGLE_STYLESHEET, winUtils.AGENT_SHEET);
} catch (e) {
// This method can fail with NS_ERROR_INVALID_ARG if the sheet is
// already loaded.
if (e.result != Cr.NS_ERROR_INVALID_ARG) {
throw e;
}
}
let container = this.content.document.createElement("div");
container.id = FLYOUT_TOGGLE_CONTAINER;
let toggle = this.content.document.createElement("button");
toggle.classList.add("picture-in-picture-toggle-button");
toggle.id = FLYOUT_TOGGLE_ID;
let icon = this.content.document.createElement("div");
icon.classList.add("icon");
toggle.appendChild(icon);
let label = this.content.document.createElement("span");
label.classList.add("label");
label.textContent = await this.flyoutLabel;
toggle.appendChild(label);
container.appendChild(toggle);
state.flyoutToggle =
this.content.document.insertAnonymousContent(container);
let { top, left, width } =
this.calculateTogglePosition(video, state.flyoutToggle, FLYOUT_TOGGLE_ID);
let styles = `
top: ${top}px;
left: ${left}px;
`;
let flyout = state.flyoutToggle;
flyout.setAttributeForElement(FLYOUT_TOGGLE_CONTAINER, "style", styles);
let flyoutAnim = flyout.setAnimationForElement(FLYOUT_TOGGLE_ID, [
{ transform: `translateX(calc(100% - ${width}px))`, opacity: "0.2" },
{ transform: `translateX(calc(100% - ${width}px))`, opacity: "0.8" },
{ transform: "translateX(0)", opacity: "1" },
], FLYOUT_ANIMATION_RUNTIME_MS);
await flyoutAnim.finished;
await new Promise(resolve => this.content.setTimeout(resolve,
this.flyoutWaitMs));
flyoutAnim.reverse();
await flyoutAnim.finished;
this.hideFlyout();
}
/**
* Once the flyout has finished animating, or Picture-in-Picture has been
* requested, this function can be called to hide it.
*/
hideFlyout() {
let state = this.docState;
let flyout = state.flyoutToggle;
if (flyout) {
flyout.setAttributeForElement(FLYOUT_TOGGLE_CONTAINER, "hidden", "true");
}
}
}
class PictureInPictureChild extends ActorChild {
static videoIsPlaying(video) {

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

@ -0,0 +1,13 @@
# 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/.
### These strings are used in the video controls.
# This string is used when displaying the Picture-in-Picture "flyout" toggle.
# The "flyout" toggle is a variation of the Picture-in-Picture video toggle that
# appears in a ribbon over top of <video> elements when Picture-in-Picture is
# enabled. This variation only appears on the first <video> that's displayed to
# a user on a page. It animates out, displaying this string, and after 5
# seconds, animates away again.
picture-in-picture-flyout-toggle = Picture-in-Picture

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

@ -231,6 +231,16 @@ let ACTORS = {
},
},
PictureInPictureToggle: {
child: {
allFrames: true,
module: "resource://gre/actors/PictureInPictureChild.jsm",
events: {
"canplay": {capture: true, mozSystemGroup: true},
},
},
},
PopupBlocking: {
child: {
module: "resource://gre/actors/PopupBlockingChild.jsm",

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

@ -112,5 +112,6 @@ toolkit.jar:
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)
skin/classic/global/pictureinpicture/toggle.css (../../shared/pictureinpicture/toggle.css)
skin/classic/global/media/pictureinpicture.svg (../../shared/media/pictureinpicture.svg)

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

@ -0,0 +1,83 @@
/**
* We add the #picture-in-picture-flyout-container and
* #picture-in-picture-toggle IDs here so that it's easier to read these
* property values in script, since they're AnonymousContent, and we need
* IDs and can't use classes to query AnonymousContent property values.
*/
#picture-in-picture-flyout-container:-moz-native-anonymous,
#picture-in-picture-toggle:-moz-native-anonymous,
.picture-in-picture-toggle-button:-moz-native-anonymous {
--pip-toggle-bgcolor: rgb(0, 96, 223);
--pip-toggle-text-and-icon-color: rgb(255, 255, 255);
--pip-toggle-padding: 5px;
--pip-toggle-icon-width-height: 16px;
}
.picture-in-picture-toggle-button:-moz-native-anonymous {
-moz-appearance: none;
display: flex;
position: absolute;
background-color: var(--pip-toggle-bgcolor);
border: 0;
padding: var(--pip-toggle-padding);
color: var(--pip-toggle-text-and-icon-color);
transform: translateX(0);
transition: transform 350ms linear;
min-width: max-content;
pointer-events: auto;
opacity: 0.8;
}
.picture-in-picture-toggle-button:-moz-native-anonymous:hover,
.picture-in-picture-toggle-button:-moz-native-anonymous:active {
opacity: 1;
background-color: var(--pip-toggle-bgcolor);
color: var(--pip-toggle-text-and-icon-color);
padding: var(--pip-toggle-padding);
}
#picture-in-picture-flyout-container[hidden]:-moz-native-anonymous,
.picture-in-picture-toggle-button[hidden]:-moz-native-anonymous {
display: none;
}
.picture-in-picture-toggle-button:-moz-native-anonymous > .icon {
display: inline-block;
background-image: url(chrome://global/skin/media/pictureinpicture.svg);
background-position: center left;
background-repeat: no-repeat;
-moz-context-properties: fill, stroke;
fill: var(--pip-toggle-text-and-icon-color);
stroke: var(--pip-toggle-text-and-icon-color);
width: var(--pip-toggle-icon-width-height);
height: var(--pip-toggle-icon-width-height);
min-width: max-content;
pointer-events: none;
}
.picture-in-picture-toggle-button:-moz-native-anonymous > .label {
margin-left: var(--pip-toggle-padding);
min-width: max-content;
pointer-events: none;
}
#picture-in-picture-flyout-container:-moz-native-anonymous {
position: absolute;
/**
* A higher z-index makes sure that the flyout always appears on top of the
* other toggle, so that we avoid seeing double-toggles.
*/
z-index: 2;
overflow: hidden;
/**
* This places the container for the flyout in the position where the flyout
* eventually ends up. This, coupled with the overflow: hidden, gives the
* effect that the flyout is sliding out from the edge of the video.
*/
transform: translateX(calc(-100% + var(--pip-toggle-icon-width-height) + 2 * var(--pip-toggle-padding)));
}
#picture-in-picture-flyout-container:-moz-native-anonymous > .picture-in-picture-toggle-button {
position: relative;
opacity: 1;
}