diff options
Diffstat (limited to 'toolkit/components/pictureinpicture/content')
-rw-r--r-- | toolkit/components/pictureinpicture/content/player.js | 669 | ||||
-rw-r--r-- | toolkit/components/pictureinpicture/content/player.xhtml | 57 |
2 files changed, 726 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js new file mode 100644 index 0000000000..8aeb91bbfe --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.js @@ -0,0 +1,669 @@ +/* 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/. */ + +const { PictureInPicture } = ChromeUtils.import( + "resource://gre/modules/PictureInPicture.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { DeferredTask } = ChromeUtils.import( + "resource://gre/modules/DeferredTask.jsm" +); +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +const AUDIO_TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.audio-toggle.enabled"; +const KEYBOARD_CONTROLS_ENABLED_PREF = + "media.videocontrols.picture-in-picture.keyboard-controls.enabled"; + +// Time to fade the Picture-in-Picture video controls after first opening. +const CONTROLS_FADE_TIMEOUT_MS = 3000; +const RESIZE_DEBOUNCE_RATE_MS = 500; + +/** +Quadrants! +* 2 | 1 +* 3 | 4 +*/ +const TOP_RIGHT_QUADRANT = 1; +const TOP_LEFT_QUADRANT = 2; +const BOTTOM_LEFT_QUADRANT = 3; +const BOTTOM_RIGHT_QUADRANT = 4; + +/** + * Public function to be called from PictureInPicture.jsm. This is the main + * entrypoint for initializing the player window. + * + * @param id (Number) + * A unique numeric ID for the window, used for Telemetry Events. + * @param wgp (WindowGlobalParent) + * The WindowGlobalParent that is hosting the originating video. + * @param videoRef {ContentDOMReference} + * A reference to the video element that a Picture-in-Picture window + * is being created for + */ +function setupPlayer(id, wgp, videoRef) { + Player.init(id, wgp, videoRef); +} + +/** + * Public function to be called from PictureInPicture.jsm. This update the + * controls based on whether or not the video is playing. + * + * @param isPlaying (Boolean) + * True if the Picture-in-Picture video is playing. + */ +function setIsPlayingState(isPlaying) { + Player.isPlaying = isPlaying; +} + +/** + * Public function to be called from PictureInPicture.jsm. This update the + * controls based on whether or not the video is muted. + * + * @param isMuted (Boolean) + * True if the Picture-in-Picture video is muted. + */ +function setIsMutedState(isMuted) { + Player.isMuted = isMuted; +} + +/** + * The Player object handles initializing the player, holds state, and handles + * events for updating state. + */ +let Player = { + WINDOW_EVENTS: [ + "click", + "contextmenu", + "dblclick", + "keydown", + "mouseup", + "MozDOMFullscreen:Entered", + "MozDOMFullscreen:Exited", + "resize", + "unload", + ], + actor: null, + /** + * Used for resizing Telemetry to avoid recording an event for every resize + * event. Instead, we wait until RESIZE_DEBOUNCE_RATE_MS has passed since the + * last resize event before recording. + */ + resizeDebouncer: null, + /** + * Used for window movement Telemetry to determine if the player window has + * moved since the last time we checked. + */ + lastScreenX: -1, + lastScreenY: -1, + id: -1, + + /** + * When set to a non-null value, a timer is scheduled to hide the controls + * after CONTROLS_FADE_TIMEOUT_MS milliseconds. + */ + showingTimeout: null, + + /** + * Used to determine old window location when mouseup-ed for corner + * snapping drag vector calculation + */ + oldMouseUpWindowX: window.screenX, + oldMouseUpWindowY: window.screenY, + + /** + * Initializes the player browser, and sets up the initial state. + * + * @param id (Number) + * A unique numeric ID for the window, used for Telemetry Events. + * @param wgp (WindowGlobalParent) + * The WindowGlobalParent that is hosting the originating video. + * @param videoRef {ContentDOMReference} + * A reference to the video element that a Picture-in-Picture window + * is being created for + */ + init(id, wgp, videoRef) { + this.id = id; + + let holder = document.querySelector(".player-holder"); + let browser = document.getElementById("browser"); + browser.remove(); + + browser.setAttribute("nodefaultsrc", "true"); + + // Set the specific remoteType and browsingContextGroupID to use for the + // initial about:blank load. The combination of these two properties will + // ensure that the browser loads in the same process as our originating + // browser. + browser.setAttribute("remoteType", wgp.domProcess.remoteType); + browser.setAttribute( + "initialBrowsingContextGroupId", + wgp.browsingContext.group.id + ); + holder.appendChild(browser); + + this.actor = browser.browsingContext.currentWindowGlobal.getActor( + "PictureInPicture" + ); + this.actor.sendAsyncMessage("PictureInPicture:SetupPlayer", { + videoRef, + }); + + PictureInPicture.weakPipToWin.set(this.actor, window); + + for (let eventType of this.WINDOW_EVENTS) { + addEventListener(eventType, this); + } + + // If the content process hosting the video crashes, let's + // just close the window for now. + browser.addEventListener("oop-browser-crashed", this); + + this.revealControls(false); + + if (Services.prefs.getBoolPref(AUDIO_TOGGLE_ENABLED_PREF, false)) { + const audioButton = document.getElementById("audio"); + audioButton.hidden = false; + audioButton.previousElementSibling.hidden = false; + } + + Services.telemetry.setEventRecordingEnabled("pictureinpicture", true); + + this.resizeDebouncer = new DeferredTask(() => { + this.recordEvent("resize", { + width: window.outerWidth.toString(), + height: window.outerHeight.toString(), + }); + }, RESIZE_DEBOUNCE_RATE_MS); + + this.lastScreenX = window.screenX; + this.lastScreenY = window.screenY; + + this.recordEvent("create", { + width: window.outerWidth.toString(), + height: window.outerHeight.toString(), + screenX: window.screenX.toString(), + screenY: window.screenY.toString(), + }); + + this.computeAndSetMinimumSize(window.outerWidth, window.outerHeight); + + // alwaysontop windows are not focused by default, so we have to do it + // ourselves. We use requestAnimationFrame since we have to wait until the + // window is visible before it can focus. + window.requestAnimationFrame(() => { + window.focus(); + }); + }, + + uninit() { + this.resizeDebouncer.disarm(); + PictureInPicture.unload(window, this.actor); + }, + + handleEvent(event) { + switch (event.type) { + case "click": { + this.onClick(event); + this.controls.removeAttribute("keying"); + break; + } + + case "contextmenu": { + event.preventDefault(); + break; + } + + case "dblclick": { + this.onDblClick(event); + break; + } + + case "keydown": { + if (event.keyCode == KeyEvent.DOM_VK_TAB) { + this.controls.setAttribute("keying", true); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + this.controls.hasAttribute("keying") + ) { + this.controls.removeAttribute("keying"); + + // We preventDefault to avoid exiting fullscreen if we happen + // to be in it. + event.preventDefault(); + } else if ( + Services.prefs.getBoolPref(KEYBOARD_CONTROLS_ENABLED_PREF, false) && + (event.keyCode != KeyEvent.DOM_VK_SPACE || !event.target.id) + ) { + // Pressing "space" fires a "keydown" event which can also trigger a control + // button's "click" event. Handle the "keydown" event only when the event did + // not originate from a control button and it is not a "space" keypress. + this.onKeyDown(event); + } + + break; + } + + case "mouseup": { + this.onMouseUp(event); + break; + } + + // Normally, the DOMFullscreenParent / DOMFullscreenChild actors + // would take care of firing the `fullscreen-painted` notification, + // however, those actors are only ever instantiated when a <browser> + // is fullscreened, and not a <body> element in a parent-process + // chrome privileged DOM window. + // + // Rather than trying to re-engineer JSWindowActors to be re-usable for + // this edge-case, we do the work of firing fullscreen-painted when + // transitioning in and out of fullscreen ourselves here. + case "MozDOMFullscreen:Entered": + // Intentional fall-through + case "MozDOMFullscreen:Exited": { + let { lastTransactionId } = window.windowUtils; + window.addEventListener("MozAfterPaint", function onPainted(event) { + if (event.transactionId > lastTransactionId) { + window.removeEventListener("MozAfterPaint", onPainted); + Services.obs.notifyObservers(window, "fullscreen-painted"); + } + }); + break; + } + + case "oop-browser-crashed": { + this.closePipWindow({ reason: "browser-crash" }); + break; + } + + case "resize": { + this.onResize(event); + break; + } + + case "unload": { + this.uninit(); + break; + } + } + }, + + closePipWindow(closeData) { + const { reason } = closeData; + + if (PictureInPicture.isMultiPipEnabled) { + PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor }); + } else { + PictureInPicture.closeAllPipWindows({ reason }); + } + }, + + onDblClick(event) { + if (event.target.id == "controls") { + if (document.fullscreenElement == document.body) { + document.exitFullscreen(); + } else { + document.body.requestFullscreen(); + } + event.preventDefault(); + } + }, + + onClick(event) { + switch (event.target.id) { + case "audio": { + if (this.isMuted) { + this.actor.sendAsyncMessage("PictureInPicture:Unmute"); + } else { + this.actor.sendAsyncMessage("PictureInPicture:Mute"); + } + break; + } + + case "close": { + this.actor.sendAsyncMessage("PictureInPicture:Pause", { + reason: "pip-closed", + }); + this.closePipWindow({ reason: "close-button" }); + break; + } + + case "playpause": { + if (!this.isPlaying) { + this.actor.sendAsyncMessage("PictureInPicture:Play"); + this.revealControls(false); + } else { + this.actor.sendAsyncMessage("PictureInPicture:Pause"); + this.revealControls(true); + } + + break; + } + + case "unpip": { + PictureInPicture.focusTabAndClosePip(window, this.actor); + break; + } + } + }, + + onKeyDown(event) { + this.actor.sendAsyncMessage("PictureInPicture:KeyDown", { + altKey: event.altKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + keyCode: event.keyCode, + }); + }, + + /** + * PiP Corner Snapping Helper Function + * Determines the quadrant the PiP window is currently in. + */ + determineCurrentQuadrant() { + // Determine center coordinates of window. + let windowCenterX = window.screenX + window.innerWidth / 2; + let windowCenterY = window.screenY + window.innerHeight / 2; + let quadrant = null; + let halfWidth = window.screen.availLeft + window.screen.availWidth / 2; + let halfHeight = window.screen.availTop + window.screen.availHeight / 2; + + let leftHalf = windowCenterX < halfWidth; + let rightHalf = windowCenterX > halfWidth; + let topHalf = windowCenterY < halfHeight; + let bottomHalf = windowCenterY > halfHeight; + + if (leftHalf && topHalf) { + quadrant = TOP_LEFT_QUADRANT; + } else if (rightHalf && topHalf) { + quadrant = TOP_RIGHT_QUADRANT; + } else if (leftHalf && bottomHalf) { + quadrant = BOTTOM_LEFT_QUADRANT; + } else if (rightHalf && bottomHalf) { + quadrant = BOTTOM_RIGHT_QUADRANT; + } + return quadrant; + }, + + /** + * Helper function to actually move/snap the PiP window. + * Moves the PiP window to the top right. + */ + moveToTopRight() { + window.moveTo( + window.screen.availLeft + window.screen.availWidth - window.innerWidth, + window.screen.availTop + ); + }, + + /** + * Moves the PiP window to the top left. + */ + moveToTopLeft() { + window.moveTo(window.screen.availLeft, window.screen.availTop); + }, + + /** + * Moves the PiP window to the bottom right. + */ + moveToBottomRight() { + window.moveTo( + window.screen.availLeft + window.screen.availWidth - window.innerWidth, + window.screen.availTop + window.screen.availHeight - window.innerHeight + ); + }, + + /** + * Moves the PiP window to the bottom left. + */ + moveToBottomLeft() { + window.moveTo( + window.screen.availLeft, + window.screen.availTop + window.screen.availHeight - window.innerHeight + ); + }, + + determineDirectionDragged() { + // Determine change in window location. + let deltaX = this.oldMouseUpWindowX - window.screenX; + let deltaY = this.oldMouseUpWindowY - window.screenY; + let dragDirection = ""; + + if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) { + dragDirection = "draggedRight"; + } else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) { + dragDirection = "draggedLeft"; + } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY < 0) { + dragDirection = "draggedDown"; + } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY > 0) { + dragDirection = "draggedUp"; + } + return dragDirection; + }, + + onMouseUp(event) { + if ( + window.screenX != this.lastScreenX || + window.screenY != this.lastScreenY + ) { + this.recordEvent("move", { + screenX: window.screenX.toString(), + screenY: window.screenY.toString(), + }); + } + this.lastScreenX = window.screenX; + this.lastScreenY = window.screenY; + + // Corner snapping changes start here. + // Check if metakey pressed and macOS + let quadrant = this.determineCurrentQuadrant(); + let dragAction = this.determineDirectionDragged(); + + if (event.metaKey && AppConstants.platform == "macosx" && dragAction) { + // Moving logic based on current quadrant and direction of drag. + switch (quadrant) { + case TOP_RIGHT_QUADRANT: + switch (dragAction) { + case "draggedRight": + this.moveToTopRight(); + break; + case "draggedLeft": + this.moveToTopLeft(); + break; + case "draggedDown": + this.moveToBottomRight(); + break; + case "draggedUp": + this.moveToTopRight(); + break; + } + break; + case TOP_LEFT_QUADRANT: + switch (dragAction) { + case "draggedRight": + this.moveToTopRight(); + break; + case "draggedLeft": + this.moveToTopLeft(); + break; + case "draggedDown": + this.moveToBottomLeft(); + break; + case "draggedUp": + this.moveToTopLeft(); + break; + } + break; + case BOTTOM_LEFT_QUADRANT: + switch (dragAction) { + case "draggedRight": + this.moveToBottomRight(); + break; + case "draggedLeft": + this.moveToBottomLeft(); + break; + case "draggedDown": + this.moveToBottomLeft(); + break; + case "draggedUp": + this.moveToTopLeft(); + break; + } + break; + case BOTTOM_RIGHT_QUADRANT: + switch (dragAction) { + case "draggedRight": + this.moveToBottomRight(); + break; + case "draggedLeft": + this.moveToBottomLeft(); + break; + case "draggedDown": + this.moveToBottomRight(); + break; + case "draggedUp": + this.moveToTopRight(); + break; + } + break; + } // Switch close. + } // Metakey close. + this.oldMouseUpWindowX = window.screenX; + this.oldMouseUpWindowY = window.screenY; + }, + + onResize(event) { + this.resizeDebouncer.disarm(); + this.resizeDebouncer.arm(); + }, + + onCommand(event) { + this.closePipWindow({ reason: "player-shortcut" }); + }, + + get controls() { + delete this.controls; + return (this.controls = document.getElementById("controls")); + }, + + _isPlaying: false, + /** + * isPlaying returns true if the video is currently playing. + * + * @return Boolean + */ + get isPlaying() { + return this._isPlaying; + }, + + /** + * Set isPlaying to true if the video is playing, false otherwise. This will + * update the internal state and displayed controls. + */ + set isPlaying(isPlaying) { + this._isPlaying = isPlaying; + this.controls.classList.toggle("playing", isPlaying); + const playButton = document.getElementById("playpause"); + let strId = "pictureinpicture-" + (isPlaying ? "pause" : "play"); + document.l10n.setAttributes(playButton, strId); + }, + + _isMuted: false, + /** + * isMuted returns true if the video is currently muted. + * + * @return Boolean + */ + get isMuted() { + return this._isMuted; + }, + + /** + * Set isMuted to true if the video is muted, false otherwise. This will + * update the internal state and displayed controls. + */ + set isMuted(isMuted) { + this._isMuted = isMuted; + this.controls.classList.toggle("muted", isMuted); + const audioButton = document.getElementById("audio"); + let strId = "pictureinpicture-" + (isMuted ? "unmute" : "mute"); + document.l10n.setAttributes(audioButton, strId); + }, + + recordEvent(type, args) { + Services.telemetry.recordEvent( + "pictureinpicture", + type, + "player", + this.id, + args + ); + }, + + /** + * Makes the player controls visible. + * + * @param revealIndefinitely (Boolean) + * If false, this will hide the controls again after + * CONTROLS_FADE_TIMEOUT_MS milliseconds has passed. If true, the controls + * will remain visible until revealControls is called again with + * revealIndefinitely set to false. + */ + revealControls(revealIndefinitely) { + clearTimeout(this.showingTimeout); + this.showingTimeout = null; + + this.controls.setAttribute("showing", true); + if (!revealIndefinitely) { + this.showingTimeout = setTimeout(() => { + this.controls.removeAttribute("showing"); + }, CONTROLS_FADE_TIMEOUT_MS); + } + }, + + /** + * Given a width and height for a video, computes the minimum dimensions for + * the player window, and then sets them on the root element. + * + * This is currently only used on Linux GTK, where the OS doesn't already + * impose a minimum window size. For other platforms, this function is a + * no-op. + * + * @param width (Number) + * The width of the video being played. + * @param height (Number) + * The height of the video being played. + */ + computeAndSetMinimumSize(width, height) { + if (!AppConstants.MOZ_WIDGET_GTK) { + return; + } + + // Using inspection, these seem to be the right minimums for each dimension + // so that the controls don't get too crowded. + const MIN_WIDTH = 120; + const MIN_HEIGHT = 80; + + let resultWidth = width; + let resultHeight = height; + let aspectRatio = width / height; + + // Take the smaller of the two dimensions, and set it to the minimum. + // Then calculate the other dimension using the aspect ratio to get + // both minimums. + if (width < height) { + resultWidth = MIN_WIDTH; + resultHeight = Math.round(MIN_WIDTH / aspectRatio); + } else { + resultHeight = MIN_HEIGHT; + resultWidth = Math.round(MIN_HEIGHT * aspectRatio); + } + + document.documentElement.style.minWidth = resultWidth + "px"; + document.documentElement.style.minHeight = resultHeight + "px"; + }, +}; diff --git a/toolkit/components/pictureinpicture/content/player.xhtml b/toolkit/components/pictureinpicture/content/player.xhtml new file mode 100644 index 0000000000..c0b13a414e --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.xhtml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html> +<!-- 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/. --> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + windowtype="Toolkit:PictureInPicture" + chromemargin="0,0,0,0"> + <head> + <meta charset="utf-8"/> + <link rel="stylesheet" type="text/css" + href="chrome://global/skin/pictureinpicture/player.css"/> + <link rel="localization" href="toolkit/pictureinpicture/pictureinpicture.ftl"/> + <link rel="localization" href="browser/browserSets.ftl"/> + <script src="chrome://global/content/pictureinpicture/player.js"></script> + <title data-l10n-id="pictureinpicture-player-title"></title> + </head> + + <body> + <xul:commandset> + <xul:command id="View:PictureInPicture" oncommand="Player.onCommand(event);"/> + </xul:commandset> + + <xul:keyset> +#ifndef XP_MACOSX + <xul:key data-l10n-id="picture-in-picture-toggle-shortcut" command="View:PictureInPicture" modifiers="accel,shift"/> + <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-alt" command="View:PictureInPicture" modifiers="accel,shift"/> +#else + <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-mac" command="View:PictureInPicture" modifiers="accel,alt,shift"/> + <xul:key data-l10n-id="picture-in-picture-toggle-shortcut-mac-alt" command="View:PictureInPicture" modifiers="accel,alt,shift"/> +#endif + </xul:keyset> + + <div class="player-holder"> + <xul:browser type="content" primary="true" remote="true" remoteType="web" id="browser" tabindex="-1"></xul:browser> + </div> + <div id="controls"> + <button id="close" + class="control-item" +#ifdef XP_MACOSX + mac="true" +#endif + data-l10n-id="pictureinpicture-close" + tabindex="3"/> + <div id="controls-bottom"> + <button id="unpip" class="control-item" data-l10n-id="pictureinpicture-unpip" tabindex="2"></button> + <div class="gap"></div> + <button id="playpause" class="control-item" tabindex="1" autofocus="true" + data-l10n-id="pictureinpicture-pause"/> + <div class="gap" hidden="true"></div> + <button id="audio" class="control-item" hidden="true" tabindex="1" + data-l10n-id="pictureinpicture-mute"/> + </div> + </div> + </body> +</html> |