From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- .../pictureinpicture/PictureInPicture.jsm | 827 ++++++++++++++++++++ .../pictureinpicture/PictureInPictureControls.jsm | 44 ++ .../components/pictureinpicture/content/player.js | 669 +++++++++++++++++ .../pictureinpicture/content/player.xhtml | 57 ++ .../pictureinpicture/docs/PiP-diagram.svg | 4 + toolkit/components/pictureinpicture/docs/index.rst | 190 +++++ toolkit/components/pictureinpicture/jar.mn | 8 + toolkit/components/pictureinpicture/moz.build | 21 + .../components/pictureinpicture/tests/.eslintrc.js | 5 + .../components/pictureinpicture/tests/browser.ini | 87 +++ .../tests/browser_cannotTriggerFromContent.js | 32 + .../tests/browser_closePipPause.js | 68 ++ .../pictureinpicture/tests/browser_closePlayer.js | 48 ++ .../pictureinpicture/tests/browser_closeTab.js | 25 + .../pictureinpicture/tests/browser_contextMenu.js | 238 ++++++ .../tests/browser_cornerSnapping.js | 271 +++++++ .../tests/browser_dblclickFullscreen.js | 90 +++ .../tests/browser_durationChange.js | 61 ++ .../tests/browser_flipIconWithRTL.js | 66 ++ .../pictureinpicture/tests/browser_fullscreen.js | 142 ++++ .../tests/browser_keyboardShortcut.js | 83 ++ .../tests/browser_mediaStreamVideos.js | 59 ++ .../tests/browser_mouseButtonVariation.js | 97 +++ .../pictureinpicture/tests/browser_multiPip.js | 232 ++++++ .../tests/browser_noToggleOnAudio.js | 45 ++ .../tests/browser_playerControls.js | 91 +++ .../tests/browser_removeVideoElement.js | 84 +++ .../pictureinpicture/tests/browser_rerequestPiP.js | 31 + .../pictureinpicture/tests/browser_resizeVideo.js | 263 +++++++ .../pictureinpicture/tests/browser_reversePiP.js | 145 ++++ .../tests/browser_saveLastPiPLoc.js | 362 +++++++++ .../tests/browser_shortcutsAfterFocus.js | 67 ++ .../pictureinpicture/tests/browser_showMessage.js | 29 + .../tests/browser_smallVideoLayout.js | 210 ++++++ .../tests/browser_stripVideoStyles.js | 49 ++ .../tests/browser_tabIconOverlayPiP.js | 90 +++ .../tests/browser_thirdPartyIframe.js | 47 ++ .../tests/browser_toggleAfterTabTearOutIn.js | 58 ++ .../tests/browser_toggleButtonOverlay.js | 17 + .../pictureinpicture/tests/browser_toggleMode_2.js | 202 +++++ .../tests/browser_toggleOnInsertedVideo.js | 42 ++ .../tests/browser_toggleOpaqueOverlay.js | 16 + .../tests/browser_togglePointerEventsNone.js | 16 + .../tests/browser_togglePolicies.js | 126 ++++ .../pictureinpicture/tests/browser_toggleSimple.js | 18 + .../tests/browser_toggleTransparentOverlay-1.js | 33 + .../tests/browser_toggleTransparentOverlay-2.js | 33 + .../tests/browser_videoSelection.js | 106 +++ .../pictureinpicture/tests/click-event-helper.js | 26 + toolkit/components/pictureinpicture/tests/head.js | 835 +++++++++++++++++++++ .../components/pictureinpicture/tests/short.mp4 | Bin 0 -> 38713 bytes .../tests/test-button-overlay.html | 81 ++ .../pictureinpicture/tests/test-media-stream.html | 25 + .../tests/test-opaque-overlay.html | 51 ++ .../tests/test-page-with-iframe.html | 27 + .../tests/test-page-with-sound.html | 20 + .../pictureinpicture/tests/test-page.html | 30 + .../tests/test-pointer-events-none.html | 21 + .../pictureinpicture/tests/test-reversed.html | 19 + .../tests/test-transparent-nested-iframes.html | 51 ++ .../tests/test-transparent-overlay-1.html | 46 ++ .../tests/test-transparent-overlay-2.html | 46 ++ .../pictureinpicture/tests/test-video-cropped.mp4 | Bin 0 -> 36502 bytes .../pictureinpicture/tests/test-video-long.mp4 | Bin 0 -> 344085 bytes .../tests/test-video-selection.html | 22 + .../pictureinpicture/tests/test-video-vertical.mp4 | Bin 0 -> 36502 bytes .../pictureinpicture/tests/test-video.mp4 | Bin 0 -> 242969 bytes 67 files changed, 6904 insertions(+) create mode 100644 toolkit/components/pictureinpicture/PictureInPicture.jsm create mode 100644 toolkit/components/pictureinpicture/PictureInPictureControls.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/docs/PiP-diagram.svg create mode 100644 toolkit/components/pictureinpicture/docs/index.rst create mode 100644 toolkit/components/pictureinpicture/jar.mn create mode 100644 toolkit/components/pictureinpicture/moz.build create mode 100644 toolkit/components/pictureinpicture/tests/.eslintrc.js create mode 100644 toolkit/components/pictureinpicture/tests/browser.ini create mode 100644 toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closePipPause.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closePlayer.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closeTab.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_contextMenu.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_durationChange.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_fullscreen.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_multiPip.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_playerControls.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_resizeVideo.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_reversePiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_showMessage.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_togglePolicies.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleSimple.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_videoSelection.js create mode 100644 toolkit/components/pictureinpicture/tests/click-event-helper.js create mode 100644 toolkit/components/pictureinpicture/tests/head.js create mode 100644 toolkit/components/pictureinpicture/tests/short.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-button-overlay.html create mode 100644 toolkit/components/pictureinpicture/tests/test-media-stream.html create mode 100644 toolkit/components/pictureinpicture/tests/test-opaque-overlay.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-iframe.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-sound.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page.html create mode 100644 toolkit/components/pictureinpicture/tests/test-pointer-events-none.html create mode 100644 toolkit/components/pictureinpicture/tests/test-reversed.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html create mode 100644 toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video-long.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video-selection.html create mode 100644 toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video.mp4 (limited to 'toolkit/components/pictureinpicture') diff --git a/toolkit/components/pictureinpicture/PictureInPicture.jsm b/toolkit/components/pictureinpicture/PictureInPicture.jsm new file mode 100644 index 0000000000..f1f17fd3f0 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPicture.jsm @@ -0,0 +1,827 @@ +/* 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 pipActorRef (PictureInPictureParent) + * 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; + }, + + /** + * Called when the browser UI handles the View:PictureInPicture command via + * the keyboard. + */ + 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 pipWin {Window} + * 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 wgp (WindowGlobalParent) + * The WindowGlobalParent 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(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); + + 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. + */ + unload(window) { + TelemetryStopwatch.finish( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + 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 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. + * + * @param actorReference (PictureInPictureParent) + * 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 + ); + + TelemetryStopwatch.start( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + pipWindow, + { + inSeconds: true, + } + ); + + 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 requestingWin (chrome window|player window) + * 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 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 (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 }; + }, + + 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); + }, + + 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 + * 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 left + * left or x coordinate + * @param top + * top or y coordinate + * + * @returns 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 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 +); diff --git a/toolkit/components/pictureinpicture/PictureInPictureControls.jsm b/toolkit/components/pictureinpicture/PictureInPictureControls.jsm new file mode 100644 index 0000000000..e04e2e62f2 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPictureControls.jsm @@ -0,0 +1,44 @@ +/* -*- 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 = [ + "KEYBOARD_CONTROLS", + "TOGGLE_POLICIES", + "TOGGLE_POLICY_STRINGS", +]; + +// These denote which keyboard controls to show for a qualified video element. +this.KEYBOARD_CONTROLS = { + NONE: 0, + PLAY_PAUSE: 1 << 0, + MUTE_UNMUTE: 1 << 1, + VOLUME: 1 << 2, + SEEK: 1 << 3, +}; + +// These are the possible toggle positions along the right side of +// a qualified video element. +this.TOGGLE_POLICIES = { + DEFAULT: 1, + HIDDEN: 2, + TOP: 3, + ONE_QUARTER: 4, + THREE_QUARTERS: 5, + BOTTOM: 6, +}; + +// These strings are used in the videocontrols.css stylesheet as +// toggle policy attribute values for setting rules on the position +// of the toggle. +this.TOGGLE_POLICY_STRINGS = { + [TOGGLE_POLICIES.DEFAULT]: "default", + [TOGGLE_POLICIES.HIDDEN]: "hidden", + [TOGGLE_POLICIES.TOP]: "top", + [TOGGLE_POLICIES.ONE_QUARTER]: "one-quarter", + [TOGGLE_POLICIES.THREE_QUARTERS]: "three-quarters", + [TOGGLE_POLICIES.BOTTOM]: "bottom", +}; 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 + // is fullscreened, and not a 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 @@ + + + + + + + + + + + + + + + + + + + +#ifndef XP_MACOSX + + +#else + + +#endif + + +
+ +
+
+ +
+
+ + + diff --git a/toolkit/components/pictureinpicture/docs/PiP-diagram.svg b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg new file mode 100644 index 0000000000..ffff499660 --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg @@ -0,0 +1,4 @@ + +
Twitch
Twitch
https://www.twitch.tv
https://www.twitch.tv
Video
Video
Content process hosting the video

Content process hosting the video
videocontrols.js
videocontrols.js
MozTogglePictureInPicture chrome event
MozTogglePictureInPicture chrome event
JSWindowActor messaging
JSWindowActor messaging
PictureInPictureToggleChild
PictureInPictureToggleChild
JSWindowActor messaging
JSWindowActor messaging
PictureInPictureLauncherChild
PictureInPictureLauncherChild
PictureInPictureChild for player <video>
PictureInPictureChild for player <video>
PictureInPictureToggleParent
PictureInPictureToggleParent
PictureInPictureLauncherParent
PictureInPictureLauncherParent
PictureInPicture.jsm

PictureInPicture.jsm
Parent process
Parent process
player.xhtml / player.js - alwaysontop window
player.xhtml / player.js - alwaysontop wi...
remote <xul:browser> running in the same content process as
original video
remote <xul:browser> running in the same content process...
Player video
Player video
JSWindowActor
Messaging
JSWindowActor...
PictureInPictureParent
PictureInPictureParent
Services.ww.openWindow
Servic...
\ No newline at end of file diff --git a/toolkit/components/pictureinpicture/docs/index.rst b/toolkit/components/pictureinpicture/docs/index.rst new file mode 100644 index 0000000000..81984c2b94 --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/index.rst @@ -0,0 +1,190 @@ +.. _components/pictureinpicture: + +================== +Picture-in-Picture +================== + +This component makes it possible for a ``